app/models/ProjectEntry.scala (338 lines of code) (raw):
package models
import akka.stream.Materializer
import akka.stream.scaladsl.{Keep, Sink, Source}
import slick.jdbc.PostgresProfile.api._
import slick.lifted.TableQuery
import java.sql.Timestamp
import java.time.LocalDateTime
import org.joda.time.DateTime
import org.joda.time.DateTimeZone.UTC
import play.api.libs.functional.syntax._
import play.api.libs.json._
import play.api.Configuration
import scala.concurrent.Future
import scala.util.{Failure, Success, Try}
import scala.concurrent.ExecutionContext.Implicits.global
case class ProjectEntry (id: Option[Int], projectTypeId: Int, vidispineProjectId: Option[String],
projectTitle: String, created:Timestamp, updated:Timestamp, user: String, workingGroupId: Option[Int],
commissionId: Option[Int], deletable: Option[Boolean], deep_archive: Option[Boolean],
sensitive: Option[Boolean], status:EntryStatus.Value, productionOffice: ProductionOffice.Value, isObitProject:Option[String],
confidential: Option[Boolean])
extends PlutoModel{
def projectDefaultStorage(implicit db:slick.jdbc.PostgresProfile#Backend#Database): Future[Option[StorageEntry]] = {
import cats.implicits._
for {
defaults <- db.run(TableQuery[DefaultsRow]
.filter(_.name === "project_storage_id").result)
.map(_.headOption)
.map(_.flatMap(entry=>Try { entry.value.toInt }.toOption))
entry <- defaults.map(entryId=>
db.run(
TableQuery[StorageEntryRow].filter(_.id===entryId).result
).map(_.headOption)
).sequence.map(_.flatten)
} yield entry
}
private def projectFilesLookupQuery(maybeLimitStorage:Option[Int]) = TableQuery[FileAssociationRow]
.filter(_.projectEntry===id.get)
.join(TableQuery[FileEntryRow])
.on(_.fileEntry===_.id)
.filterOpt(maybeLimitStorage)(_._2.storage===_)
.sortBy(_._2.version.desc.nullsLast)
/**
* looks up the files known to be associated with this projectEntry in the database
* @param allVersions if `true`, then all versions are returned across all storages with the highest version first and
* un-set versions last. If `false`, then only results on the project default storage (assumed unversioned)
* are returned
* @param db implicitly provided database object
* @return a Future, containing a Sequence of matching FileEntry objects
*/
def associatedFiles(allVersions:Boolean)(implicit db:slick.jdbc.PostgresProfile#Backend#Database): Future[Seq[FileEntry]] = {
def lookupProjectFiles(maybeLimitStorage:Option[Int]) = db.run {
projectFilesLookupQuery(maybeLimitStorage).result
}.map(_.map(_._2))
for {
defaultStorage <- projectDefaultStorage
result <- lookupProjectFiles(if(allVersions) None else defaultStorage.flatMap(_.id))
} yield result
}
def mostRecentBackup(implicit db:slick.jdbc.PostgresProfile#Backend#Database, mat:Materializer, maybeStorageId:Option[Int]) = {
Source.fromPublisher(db.stream(projectFilesLookupQuery(Some(maybeStorageId.get)).result))
.toMat(Sink.headOption)(Keep.right)
.run()
.map(_.map(_._2))
}
/**
* Gets the working group record associated with this project entry
* @param db implicitly provided database object
* @return a Future, containing a Try representing whether the db operation succeeded, containing an Option which has the working group, if there is one.
*/
def getWorkingGroup(implicit db:slick.jdbc.PostgresProfile#Backend#Database):Future[Option[PlutoWorkingGroup]] = {
workingGroupId match {
case None=>Future(None)
case Some(groupId)=>
db.run(
TableQuery[PlutoWorkingGroupRow].filter(_.id===groupId).result.asTry
).map({
case Success(matchingEntries)=>matchingEntries.headOption //should only ever be one or zero matches as id is a unique primary key
case Failure(error)=>throw error
})
}
}
/**
* Gets the commission record associated with this project entry
* @param db implicitly provided database object
* @return a Future, containing a Try representing whether the db operation succeeded, containing an Option which has the working group, if there is one.
*/
def getCommission(implicit db:slick.jdbc.PostgresProfile#Backend#Database):Future[Option[PlutoCommission]] = {
commissionId match {
case None=>Future(None)
case Some(commId)=>
db.run(
TableQuery[PlutoCommissionRow].filter(_.id===commId).result.asTry
).map({
case Success(matchingEntries)=>matchingEntries.headOption //should only ever be one or zero matches as id is a unique primary key
case Failure(error)=>throw error
})
}
}
/**
* updates the commission field only
* @param db
*/
def saveCommission(implicit db:slick.jdbc.PostgresProfile#Backend#Database) = db.run {
TableQuery[ProjectEntryRow].filter(_.id===this.id).map(_.commission).update(this.commissionId)
}
def save(implicit db:slick.jdbc.PostgresProfile#Backend#Database):Future[Try[ProjectEntry]] = id match {
case None=>
val insertQuery = TableQuery[ProjectEntryRow] returning TableQuery[ProjectEntryRow].map(_.id) into ((item,id)=>item.copy(id=Some(id)))
db.run(
(insertQuery+=this).asTry
).map({
case Success(insertResult)=>Success(insertResult)
case Failure(error)=>Failure(error)
})
case Some(realEntityId)=>
db.run(
TableQuery[ProjectEntryRow].filter(_.id===realEntityId).update(this).asTry
).map({
case Success(rowsAffected)=>Success(this)
case Failure(error)=>Failure(error)
})
}
def removeFromDatabase(implicit db:slick.jdbc.PostgresProfile#Backend#Database):Future[Try[Unit]] = id match {
case Some(realEntityId)=>
db.run(DBIO.seq(
TableQuery[FileAssociationRow].filter(_.projectEntry===realEntityId).delete,
TableQuery[ProjectEntryRow].filter(_.id===realEntityId).delete,
).asTry)
case None=>
Future(Failure(new RuntimeException("A record must have been saved before it can be removed from the database")))
}
/**
* returns the contents of this record as a string->string map, for passing to postrun actions
* @return
*/
def asStringMap:Map[String,String] = {
Map(
"projectId"->id.getOrElse("").toString,
"vidispineProjectId"->vidispineProjectId.getOrElse(""),
"projectTitle"->projectTitle,
"projectCreated"->created.toString,
"projectOwner"->user
)
}
private def projectAssetFolderFilesLookupQuery(maybeLimitStorage:Option[Int]) = TableQuery[AssetFolderFileEntryRow]
.filter(_.project===id.get)
.filterOpt(maybeLimitStorage)(_.storage===_)
.sortBy(_.version.desc.nullsLast)
def associatedAssetFolderFiles(allVersions:Boolean, configuration: Configuration)(implicit db:slick.jdbc.PostgresProfile#Backend#Database): Future[Seq[AssetFolderFileEntry]] = {
def lookupProjectAssetFolderFiles(maybeLimitStorage:Option[Int]) = db.run {
projectAssetFolderFilesLookupQuery(maybeLimitStorage).result
}
for {
result <- lookupProjectAssetFolderFiles(if(allVersions) None else configuration.getOptional[Int]("asset_folder_backup_storage"))
} yield result
}
}
class ProjectEntryRow(tag:Tag) extends Table[ProjectEntry](tag, "ProjectEntry") {
import EntryStatusMapper._
import ProductionOfficeMapper._
implicit val DateTimeTotimestamp =
MappedColumnType.base[DateTime, Timestamp]({d=>new Timestamp(d.getMillis)}, {t=>new DateTime(t.getTime, UTC)})
def id=column[Int]("id",O.PrimaryKey,O.AutoInc)
def projectType=column[Int]("k_project_type")
def vidispineProjectId=column[Option[String]]("s_vidispine_id")
def projectTitle=column[String]("s_title")
def created=column[Timestamp]("t_created")
def updated = column[Timestamp]("t_updated")
def user=column[String]("s_user")
def workingGroup=column[Option[Int]]("k_working_group")
def commission=column[Option[Int]]("k_commission")
def deletable = column[Option[Boolean]]("b_deletable")
def deep_archive = column[Option[Boolean]]("b_deeparchive")
def sensitive = column[Option[Boolean]]("b_sensitive")
def projectTypeKey=foreignKey("fk_project_type",projectType,TableQuery[ProjectTypeRow])(_.id)
def status = column[EntryStatus.Value]("s_status")
def productionOffice = column[ProductionOffice.Value]("s_production_office")
def isObitProject = column[Option[String]]("s_is_obit_project")
def confidential = column[Option[Boolean]]("b_confidential")
def * = (id.?, projectType, vidispineProjectId, projectTitle, created, updated, user, workingGroup, commission, deletable, deep_archive, sensitive, status, productionOffice, isObitProject, confidential) <> (ProjectEntry.tupled, ProjectEntry.unapply)
}
trait ProjectEntrySerializer extends TimestampSerialization {
import EntryStatusMapper._
import ProductionOfficeMapper._
/*https://www.playframework.com/documentation/2.5.x/ScalaJson*/
implicit val projectEntryWrites:Writes[ProjectEntry] = (
(JsPath \ "id").writeNullable[Int] and
(JsPath \ "projectTypeId").write[Int] and
(JsPath \ "vidispineId").writeNullable[String] and
(JsPath \ "title").write[String] and
(JsPath \ "created").write[Timestamp] and
(JsPath \ "updated").write[Timestamp] and
(JsPath \ "user").write[String] and
(JsPath \ "workingGroupId").writeNullable[Int] and
(JsPath \ "commissionId").writeNullable[Int] and
(JsPath \ "deletable").writeNullable[Boolean] and
(JsPath \ "deep_archive").writeNullable[Boolean] and
(JsPath \ "sensitive").writeNullable[Boolean] and
(JsPath \ "status").write[EntryStatus.Value] and
(JsPath \ "productionOffice").write[ProductionOffice.Value] and
(JsPath \ "isObitProject").writeNullable[String] and
(JsPath \ "confidential").writeNullable[Boolean]
)(unlift(ProjectEntry.unapply))
implicit val projectEntryReads:Reads[ProjectEntry] = (
(JsPath \ "id").readNullable[Int] and
(JsPath \ "projectTypeId").read[Int] and
(JsPath \ "vidispineId").readNullable[String] and
(JsPath \ "title").read[String] and
(JsPath \ "created").read[Timestamp] and
(JsPath \ "updated").read[Timestamp] and
(JsPath \ "user").read[String] and
(JsPath \ "workingGroupId").readNullable[Int] and
(JsPath \ "commissionId").readNullable[Int] and
(JsPath \ "deletable").readNullable[Boolean] and
(JsPath \ "deep_archive").readNullable[Boolean] and
(JsPath \ "sensitive").readNullable[Boolean] and
(JsPath \ "status").read[EntryStatus.Value] and
(JsPath \ "productionOffice").read[ProductionOffice.Value] and
(JsPath \ "isObitProject").readNullable[String] and
(JsPath \ "confidential").readNullable[Boolean]
)(ProjectEntry.apply _)
}
object ProjectEntry extends ((Option[Int], Int, Option[String], String, Timestamp, Timestamp, String, Option[Int], Option[Int], Option[Boolean], Option[Boolean], Option[Boolean], EntryStatus.Value, ProductionOffice.Value, Option[String], Option[Boolean])=>ProjectEntry) {
def getProjectsEligibleForStatusChange(newStatus: EntryStatus.Value, commissionId: Int): DBIO[Seq[(Int, ProjectEntry)]] = {
import EntryStatusMapper._
def getProjects(query: Query[ProjectEntryRow, ProjectEntry, Seq]) = {
query.result.map(projects => projects.map(p => (p.id.getOrElse(-1), p)))
}
val baseQuery = TableQuery[ProjectEntryRow].filter(_.commission === commissionId)
newStatus match {
case EntryStatus.Completed =>
// All projects NOT Completed or Killed should be set to Completed
val filteredQuery = baseQuery.filter(p =>
p.status =!= EntryStatus.Completed &&
p.status =!= EntryStatus.Killed
)
getProjects(filteredQuery)
case EntryStatus.Killed =>
// All projects NOT Completed or Killed should be set to Killed
val filteredQuery = baseQuery.filter(p =>
p.status =!= EntryStatus.Completed &&
p.status =!= EntryStatus.Killed
)
getProjects(filteredQuery)
case EntryStatus.Held =>
// All projects NOT Completed, Killed or Held should be set to Held
val filteredQuery = baseQuery.filter(p =>
p.status =!= EntryStatus.Completed &&
p.status =!= EntryStatus.Killed &&
p.status =!= EntryStatus.Held
)
getProjects(filteredQuery)
case EntryStatus.InProduction =>
// No changes needed for In Production
DBIO.successful(Seq.empty[(Int, ProjectEntry)])
case _ =>
DBIO.successful(Seq.empty[(Int, ProjectEntry)])
}
}
def createFromFile(sourceFile: FileEntry, projectTemplate: ProjectTemplate, title:String, created:Option[LocalDateTime],
user:String, workingGroupId: Option[Int], commissionId: Option[Int], existingVidispineId: Option[String],
deletable: Boolean, deep_archive: Boolean, sensitive: Boolean, productionOffice: ProductionOffice.Value, isObitProject:Option[String],
confidential: Boolean)
(implicit db:slick.jdbc.PostgresProfile#Backend#Database):Future[Try[ProjectEntry]] = {
createFromFile(sourceFile, projectTemplate.projectTypeId, title, created, user, workingGroupId, commissionId, existingVidispineId, deletable, deep_archive, sensitive, productionOffice, isObitProject, confidential)
}
def entryForId(requestedId: Int)(implicit db:slick.jdbc.PostgresProfile#Backend#Database):Future[Try[ProjectEntry]] = {
db.run(
TableQuery[ProjectEntryRow].filter(_.id===requestedId).result.asTry
).map(_.map(_.head))
}
def entryForIdNew(requestedId: Int)(implicit db:slick.jdbc.PostgresProfile#Backend#Database):Future[ProjectEntry] =
db.run(
TableQuery[ProjectEntryRow].filter(_.id===requestedId).result
).map(_.head)
def lookupByVidispineId(vsid: String)(implicit db:slick.jdbc.PostgresProfile#Backend#Database):Future[Try[Seq[ProjectEntry]]] =
db.run(
TableQuery[ProjectEntryRow].filter(_.vidispineProjectId===vsid).result.asTry
)
protected def insertFileAssociation(projectEntryId:Int, sourceFileId:Int)(implicit db:slick.jdbc.PostgresProfile#Backend#Database) = db.run(
(TableQuery[FileAssociationRow]+=(projectEntryId,sourceFileId)).asTry
)
private def dateTimeToTimestamp(from: LocalDateTime) = Timestamp.valueOf(from)
def projectForFileEntry(fileEntry:FileEntry)(implicit db:slick.jdbc.PostgresProfile#Backend#Database) = {
db.run {
TableQuery[FileAssociationRow]
.filter(_.fileEntry===fileEntry.id)
.join(TableQuery[ProjectEntryRow])
.on(_.projectEntry===_.id)
.map(_._2)
.result
}.map(_.headOption)
}
def createFromFile(sourceFile: FileEntry, projectTypeId: Int, title:String, created:Option[LocalDateTime],
user:String, workingGroupId: Option[Int], commissionId: Option[Int], existingVidispineId: Option[String],
deletable: Boolean, deep_archive: Boolean, sensitive: Boolean, productionOffice: ProductionOffice.Value, isObitProject:Option[String],
confidential: Boolean)
(implicit db:slick.jdbc.PostgresProfile#Backend#Database):Future[Try[ProjectEntry]] = {
/* step one - create a new project entry */
val timestamp = dateTimeToTimestamp(created.getOrElse(LocalDateTime.now()))
val entry = ProjectEntry(None, projectTypeId, existingVidispineId, title, timestamp, timestamp,
user, workingGroupId, commissionId, Some(deletable), Some(deep_archive), Some(sensitive), EntryStatus.New, productionOffice, isObitProject, Some(confidential))
val savedEntry = entry.save
/* step two - set up file association. Project entry must be saved, so this is done as a future map */
savedEntry.flatMap({
case Success(projectEntry)=>
if(projectEntry.id.isEmpty){
Future(Failure(new RuntimeException("Project entry was not saved before setting up file assoication")))
} else if(sourceFile.id.isEmpty){
Future(Failure(new RuntimeException("Source file was not saved before setting up file assoication")))
} else {
insertFileAssociation(projectEntry.id.get, sourceFile.id.get).map({
case Success(affectedRows: Int) => Success(projectEntry) //we are not interested in the rows, but the project entry object
case Failure(error) => Failure(error)
})
}
case Failure(error)=>Future(Failure(error))
})
}
/**
* get a sequence of projects that belong to the given commission
* @param commissionId ID of the commission to search for
* @param db implicitly provided database profile
* @return a Future, containing a sequence of zero or more ProjectEntryRow. On error, the future fails; catch this with .recover or .onComplete
*/
def forCommission(commissionId:Int)(implicit db:slick.jdbc.PostgresProfile#Backend#Database) = db.run(
TableQuery[ProjectEntryRow].filter(_.commission===commissionId).result
)
/*
Returns an Akka source that yields ProjectEntry objects for every project in the given statuses
*/
def scanProjectsForStatus(status:EntryStatus.Value)(implicit db:slick.jdbc.PostgresProfile#Backend#Database) = {
import EntryStatusMapper._
Source.fromPublisher(db.stream(TableQuery[ProjectEntryRow].filter(_.status===status).sortBy(_.created.desc).result))
}
def scanProjectsForStatusAndTypes(status:EntryStatus.Value, projectTypes:Array[Int])(implicit db:slick.jdbc.PostgresProfile#Backend#Database) = {
import EntryStatusMapper._
Source.fromPublisher(db.stream(TableQuery[ProjectEntryRow].filter(_.status===status).filter(_.projectType inSet(projectTypes)).sortBy(_.created.desc).result))
}
def scanProjectsForTypes(projectTypes:Array[Int])(implicit db:slick.jdbc.PostgresProfile#Backend#Database) = {
Source.fromPublisher(db.stream(TableQuery[ProjectEntryRow].filter(_.projectType inSet(projectTypes)).sortBy(_.created.desc).result))
}
def scanAllProjects(implicit db:slick.jdbc.PostgresProfile#Backend#Database) = {
Source.fromPublisher(db.stream(TableQuery[ProjectEntryRow].sortBy(_.created.desc).result))
}
/**
* Returns a list of distinct known users starting with the given prefix
* @param prefix restricts results to only users starting with this string
* @param limit only return up to this many results
* @param db implicitly provided database object
* @return a Future, containing a sequence of matching usernames
*/
def listUsers(prefix:String, limit:Int)(implicit db:slick.jdbc.PostgresProfile#Backend#Database) = db.run {
val lowerCasePrefix = prefix.toLowerCase
TableQuery[ProjectEntryRow]
.distinctOn(_.user)
.filter(_.user.toLowerCase.startsWith(lowerCasePrefix))
.filterNot(_.user.toLowerCase like s"%|%")
.groupBy(_.user).map(_._1)
.take(limit)
.result
}
/**
* Returns a list of distinct obituaries starting with the given prefix
* @param prefix restricts results to only obits starting with this string
* @param limit only return up to this many results
* @param db implicitly provided database object
* @return a Future, containing a sequence of matching usernames
*/
def listObits(prefix:String, limit:Int)(implicit db:slick.jdbc.PostgresProfile#Backend#Database) = db.run {
val lowerCasePrefix = prefix.toLowerCase
TableQuery[ProjectEntryRow]
.filter(_.isObitProject.isDefined)
.distinctOn(_.isObitProject.get)
.filter(_.isObitProject.toLowerCase like s"%$lowerCasePrefix%")
.groupBy(_.isObitProject).map(_._1)
.take(limit)
.result
}
/**
* Checks if the provided "uname" string is an exact, case-insensitive match for one that already exists in the database
* @param uname username to test
* @param db implicitly provided database object
* @return a Future, containing True if at least one user matches and False otherwise
*/
def isUserKnown(uname:String)(implicit db:slick.jdbc.PostgresProfile#Backend#Database) = db.run {
val testValue = uname.toLowerCase
TableQuery[ProjectEntryRow]
.distinctOn(_.user)
.filter(_.user.toLowerCase === testValue)
.groupBy(_.user).map(_._1)
.length
.result
}.map(count=>count>0)
def scanProjectsForType(typeId:Int)(implicit db:slick.jdbc.PostgresProfile#Backend#Database) = {
Source.fromPublisher(
db.stream(TableQuery[ProjectEntryRow].filter(_.projectType===typeId).result)
)
}
}