diff --git a/build.sbt b/build.sbt index 556bb011d..2ca33d638 100755 --- a/build.sbt +++ b/build.sbt @@ -54,6 +54,7 @@ lazy val playCommonSettings = Seq( "ore.db.DbRef", "ore.models.admin._", "ore.models.project._", + "ore.models.competition._", "ore.models.user._", "ore.models.user.role._", "ore.permission.NamedPermission", diff --git a/models/src/main/scala/ore/db/impl/schema/CompetitionEntryTable.scala b/models/src/main/scala/ore/db/impl/schema/CompetitionEntryTable.scala new file mode 100644 index 000000000..2717bf8d0 --- /dev/null +++ b/models/src/main/scala/ore/db/impl/schema/CompetitionEntryTable.scala @@ -0,0 +1,20 @@ +package ore.db.impl.schema + +import ore.db.DbRef +import ore.db.impl.OrePostgresDriver.api._ +import ore.models.competition.{Competition, CompetitionEntry} +import ore.models.project.Project +import ore.models.user.User + +class CompetitionEntryTable(tag: Tag) extends ModelTable[CompetitionEntry](tag, "project_competition_entries") { + + def projectId = column[DbRef[Project]]("project_id") + def userId = column[DbRef[User]]("user_id") + def competitionId = column[DbRef[Competition]]("competition_id") + + override def * = + (id.?, createdAt.?, (projectId, userId, competitionId)) <> (mkApply((CompetitionEntry.apply _).tupled), mkUnapply( + CompetitionEntry.unapply + )) + +} diff --git a/models/src/main/scala/ore/db/impl/schema/CompetitionEntryUserVotesTable.scala b/models/src/main/scala/ore/db/impl/schema/CompetitionEntryUserVotesTable.scala new file mode 100644 index 000000000..1c01a0450 --- /dev/null +++ b/models/src/main/scala/ore/db/impl/schema/CompetitionEntryUserVotesTable.scala @@ -0,0 +1,16 @@ +package ore.db.impl.schema + +import ore.db.DbRef +import ore.db.impl.OrePostgresDriver.api._ +import ore.models.competition.CompetitionEntry +import ore.models.user.User + +class CompetitionEntryUserVotesTable(tag: Tag) + extends AssociativeTable[CompetitionEntry, User](tag, "project_competition_entry_votes") { + + def userId = column[DbRef[User]]("user_id") + def entryId = column[DbRef[CompetitionEntry]]("entry_id") + + override def * = (entryId, userId) + +} diff --git a/models/src/main/scala/ore/db/impl/schema/CompetitionTable.scala b/models/src/main/scala/ore/db/impl/schema/CompetitionTable.scala new file mode 100644 index 000000000..db883435c --- /dev/null +++ b/models/src/main/scala/ore/db/impl/schema/CompetitionTable.scala @@ -0,0 +1,52 @@ +package ore.db.impl.schema + +import java.time.Instant + +import ore.db.DbRef +import ore.db.impl.OrePostgresDriver.api._ +import ore.db.impl.table.common.{DescriptionColumn, NameColumn} +import ore.models.competition.Competition +import ore.models.user.User + +class CompetitionTable(tag: Tag) + extends ModelTable[Competition](tag, "project_competitions") + with NameColumn[Competition] + with DescriptionColumn[Competition] { + + def userId = column[DbRef[User]]("user_id") + def startDate = column[Instant]("start_date") + def endDate = column[Instant]("end_date") + def timeZone = column[String]("time_zone") + def isVotingEnabled = column[Boolean]("is_voting_enabled") + def isStaffVotingOnly = column[Boolean]("is_staff_voting_only") + def shouldShowVoteCount = column[Boolean]("should_show_vote_count") + def isSpongeOnly = column[Boolean]("is_sponge_only") + def isSourceRequired = column[Boolean]("is_source_required") + def defaultVotes = column[Int]("default_votes") + def staffVotes = column[Int]("staff_votes") + def allowedEntries = column[Int]("allowed_entries") + def maxEntryTotal = column[Int]("max_entry_total") + + override def * = + ( + id.?, + createdAt.?, + ( + userId, + name, + description.?, + startDate, + endDate, + timeZone, + isVotingEnabled, + isStaffVotingOnly, + shouldShowVoteCount, + isSpongeOnly, + isSourceRequired, + defaultVotes, + staffVotes, + allowedEntries, + maxEntryTotal.? + ) + ) <> (mkApply((Competition.apply _).tupled), mkUnapply(Competition.unapply)) +} diff --git a/models/src/main/scala/ore/models/competition/Competition.scala b/models/src/main/scala/ore/models/competition/Competition.scala new file mode 100644 index 000000000..2c8811946 --- /dev/null +++ b/models/src/main/scala/ore/models/competition/Competition.scala @@ -0,0 +1,84 @@ +package ore.models.competition + +import scala.language.higherKinds + +import java.time.Instant + +import scala.concurrent.duration._ + +import ore.db.access.QueryView +import ore.db.impl.DefaultModelCompanion +import ore.db.impl.OrePostgresDriver.api._ +import ore.db.impl.common.{Describable, Named} +import ore.db.impl.schema.{CompetitionEntryTable, CompetitionTable} +import ore.db.{DbRef, Model, ModelQuery} +import ore.models.user.{User, UserOwned} +import ore.syntax._ + +import slick.lifted.TableQuery + +/** + * Represents a [[ore.models.project.Project]] competition. + * + * @param userId Owner ID + * @param name Competition name + * @param description Competition description + * @param startDate Date when the competition begins + * @param endDate Date when the competition ends + * @param timeZone Time zone of competition + * @param isVotingEnabled True if project voting is enabled + * @param isStaffVotingOnly True if only staff members can vote + * @param shouldShowVoteCount True if the vote count should be displayed + * @param isSpongeOnly True if only Sponge plugins are permitted in the competition + * @param isSourceRequired True if source-code is required for entry to the competition + * @param defaultVotes Default amount of votes a user has + * @param staffVotes The amount of votes staff-members have + * @param allowedEntries The amount of entries a user may submit + * @param maxEntryTotal The total amount of projects allowed in the competition + */ +case class Competition( + userId: DbRef[User], + name: String, + description: Option[String], + startDate: Instant, + endDate: Instant, + timeZone: String, + isVotingEnabled: Boolean = true, + isStaffVotingOnly: Boolean = false, + shouldShowVoteCount: Boolean = true, + isSpongeOnly: Boolean = false, + isSourceRequired: Boolean = false, + defaultVotes: Int = 1, + staffVotes: Int = 1, + allowedEntries: Int = 1, + maxEntryTotal: Option[Int] = None +) extends Named + with Describable { + + /** + * Returns the amount of time remaining in the competition. + * + * @return Time remaining in competition + */ + def timeRemaining: FiniteDuration = (this.endDate.toEpochMilli - Instant.now().toEpochMilli).millis +} +object Competition extends DefaultModelCompanion[Competition, CompetitionTable](TableQuery[CompetitionTable]) { + + implicit val query: ModelQuery[Competition] = + ModelQuery.from(this) + + implicit val competitionIsUserOwned: UserOwned[Competition] = _.userId + + implicit class CompetitionModelOps(private val self: Model[Competition]) extends AnyVal { + + /** + * Returns this competition's [[CompetitionEntry]]s. + * + * @return Competition entries + */ + def entries[V[_, _]: QueryView]( + view: V[CompetitionEntryTable, Model[CompetitionEntry]] + ): V[CompetitionEntryTable, Model[CompetitionEntry]] = + view.filterView(_.competitionId === self.id.value) + } +} diff --git a/models/src/main/scala/ore/models/competition/CompetitionEntry.scala b/models/src/main/scala/ore/models/competition/CompetitionEntry.scala new file mode 100644 index 000000000..d60972f38 --- /dev/null +++ b/models/src/main/scala/ore/models/competition/CompetitionEntry.scala @@ -0,0 +1,54 @@ +package ore.models.competition + +import scala.language.higherKinds + +import ore.db.access.ModelView +import ore.db.impl.DefaultModelCompanion +import ore.db.impl.schema.{CompetitionEntryTable, CompetitionEntryUserVotesTable} +import ore.db.{AssociationQuery, DbRef, Model, ModelQuery, ModelService} +import ore.models.project.{Project, ProjectOwned} +import ore.models.user.{User, UserOwned} + +import cats.MonadError +import slick.lifted.TableQuery + +/** + * Represents a single entry in a [[Competition]]. + * + * @param projectId Project ID + * @param userId User owner ID + * @param competitionId Competition ID + */ +case class CompetitionEntry( + projectId: DbRef[Project], + userId: DbRef[User], + competitionId: DbRef[Competition] +) { + + /** + * Returns the [[Competition]] this entry belongs to. + * + * @return Competition entry belongs to + */ + def competition[F[_]](implicit service: ModelService[F], F: MonadError[F, Throwable]): F[Model[Competition]] = + ModelView + .now(Competition) + .get(competitionId) + .getOrElseF( + F.raiseError[Model[Competition]](new IllegalStateException("Found competition entry without competition")) + ) +} +object CompetitionEntry + extends DefaultModelCompanion[CompetitionEntry, CompetitionEntryTable](TableQuery[CompetitionEntryTable]) { + + implicit val query: ModelQuery[CompetitionEntry] = + ModelQuery.from(this) + + implicit val assocEntryVotesQuery: AssociationQuery[CompetitionEntryUserVotesTable, CompetitionEntry, User] = + AssociationQuery.from[CompetitionEntryUserVotesTable, CompetitionEntry, User]( + TableQuery[CompetitionEntryUserVotesTable] + )(_.entryId, _.userId) + + implicit val competitionEntryIsUserOwned: UserOwned[CompetitionEntry] = _.userId + implicit val competitionEntryIsProjectOwned: ProjectOwned[CompetitionEntry] = _.projectId +} diff --git a/models/src/main/scala/ore/permission/NamedPermission.scala b/models/src/main/scala/ore/permission/NamedPermission.scala index 4745cdbd8..fab5924fd 100644 --- a/models/src/main/scala/ore/permission/NamedPermission.scala +++ b/models/src/main/scala/ore/permission/NamedPermission.scala @@ -31,6 +31,8 @@ object NamedPermission extends Enum[NamedPermission] { case object IsStaff extends NamedPermission(Permission.IsStaff) case object Reviewer extends NamedPermission(Permission.Reviewer) + case object EditCompetition extends NamedPermission(Permission.EditCompetition) + case object ViewHealth extends NamedPermission(Permission.ViewHealth) case object ViewIp extends NamedPermission(Permission.ViewIp) case object ViewStats extends NamedPermission(Permission.ViewStats) diff --git a/models/src/main/scala/ore/permission/package.scala b/models/src/main/scala/ore/permission/package.scala index 47f8d9881..4ea61e6cb 100644 --- a/models/src/main/scala/ore/permission/package.scala +++ b/models/src/main/scala/ore/permission/package.scala @@ -60,6 +60,8 @@ package object permission { val IsStaff = Permission(1L << 26) val Reviewer = Permission(1L << 27) + val EditCompetition = Permission(1L << 28) + val ViewHealth = Permission(1L << 32) val ViewIp = Permission(1L << 33) val ViewStats = Permission(1L << 34) diff --git a/models/src/main/scala/ore/util/StringLocaleFormatterUtils.scala b/models/src/main/scala/ore/util/StringLocaleFormatterUtils.scala index 81ee91f7c..a240e9db0 100644 --- a/models/src/main/scala/ore/util/StringLocaleFormatterUtils.scala +++ b/models/src/main/scala/ore/util/StringLocaleFormatterUtils.scala @@ -1,12 +1,12 @@ package ore.util import java.time.format.{DateTimeFormatter, FormatStyle} -import java.time.{Instant, LocalDateTime, ZoneOffset} +import java.time.{Instant, LocalDateTime, ZoneId, ZoneOffset} import java.util.Locale object StringLocaleFormatterUtils { - private val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + private val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) private val dateTimeFormat = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) /** @@ -15,7 +15,8 @@ object StringLocaleFormatterUtils { * @param instant Date to format * @return Standard formatted date */ - def prettifyDate(instant: Instant)(implicit locale: Locale): String = dateFormat.withLocale(locale).format(LocalDateTime.ofInstant(instant, ZoneOffset.UTC)) + def prettifyDate(instant: Instant)(implicit locale: Locale): String = + dateFormat.withLocale(locale).format(LocalDateTime.ofInstant(instant, ZoneOffset.UTC)) /** * Formats the specified date into the standard application form time. @@ -25,4 +26,7 @@ object StringLocaleFormatterUtils { */ def prettifyDateAndTime(instant: Instant)(implicit locale: Locale): String = dateTimeFormat.withLocale(locale).format(LocalDateTime.ofInstant(instant, ZoneOffset.UTC)) + + def localDateTime2Instant(date: LocalDateTime, timeZone: String): Instant = + date.atZone(ZoneId.of(timeZone)).toInstant } diff --git a/ore/app/assets/stylesheets/_competitions.scss b/ore/app/assets/stylesheets/_competitions.scss new file mode 100644 index 000000000..16711d676 --- /dev/null +++ b/ore/app/assets/stylesheets/_competitions.scss @@ -0,0 +1,117 @@ +.form-competition { + input[type="number"] { max-width: 40px; } + .setting-content { + text-align: right; + } +} + +.header-competitions { + .btn { margin-top: 25px; } +} + +.list-competitions { + .list-group-item { + margin-bottom: 10px; + } + + .btn-results { + margin-top: 5px; + margin-right: 5px; + } + + .counter { + color: gray; + font-weight: bold; + } + + .competition-expand { + .down { + -webkit-transform: rotate(90deg); + -moz-transform: rotate(90deg); + -ms-transform: rotate(90deg); + -o-transform: rotate(90deg); + transform: rotate(90deg); + } + + .fa-chevron-right { + font-size: 75%; + -webkit-transition: all 0.1s linear; + -moz-transition: all 0.1s linear; + -ms-transition: all 0.1s linear; + -o-transition: all 0.1s linear; + transition: all 0.1s linear; + } + } + + .competition-drawer { + display: none; + z-index: -1; + height: 0; + width: 100%; + background-color: white; + border-top: 1px solid #ddd; + + .col-drawer { + padding-top: 20px; + input[type="number"] { max-width: 40px; } + table td { + padding: 5px; + } + } + + .row:last-child { + border-top: 1px solid #ddd; + padding: 10px; + } + + .competition-delete { + position: absolute; + right: 15px; + top: 55px; + } + + select[name="time-zone"] { + margin-top: 5px; + } + } + +} + +.comp-container .row:not(:last-child) { + margin-bottom: 20px; +} + +.comp-header { + .banner { + position: relative; + line-height: 100px; + text-align: center; + font-size: 18px; + + .progress { + margin-bottom: 0; + width: 100%; + border-radius: 0 0 4px 4px; + max-height: 10px; + visibility: hidden; + background-color: #ddd; + } + + .banner-image { + width: 100%; + } + + .banner-edit { + display: none; + width: 100%; + height: 100%; + position: absolute; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1000; + top: 0; + a { + color: white; + } + } + } +} diff --git a/ore/app/assets/stylesheets/_nav.scss b/ore/app/assets/stylesheets/_nav.scss index 23651c0da..a67a1fdd5 100644 --- a/ore/app/assets/stylesheets/_nav.scss +++ b/ore/app/assets/stylesheets/_nav.scss @@ -81,7 +81,7 @@ } } -.authors-icon, .staff-icon { +.authors-icon, .staff-icon, .competition-icon { margin-top: 19px; } diff --git a/ore/app/assets/stylesheets/main.scss b/ore/app/assets/stylesheets/main.scss index ebc28a7a7..1cb15ad30 100644 --- a/ore/app/assets/stylesheets/main.scss +++ b/ore/app/assets/stylesheets/main.scss @@ -1,4 +1,5 @@ @import 'admin'; +@import 'competitions'; @import 'creator'; @import 'discuss'; @import 'editor'; @@ -46,7 +47,7 @@ body { margin-bottom: 20px; } -.sponsor { +.sponsor-main { .logo { max-width: 100%; max-height: 40px; @@ -74,6 +75,12 @@ body { } } +.sponsor { + @include rounded-corners(); + background-color: #ddd; + border: 1px solid $light; +} + form { @include margin-vert(10px, 10px); } diff --git a/ore/app/controllers/project/Competitions.scala b/ore/app/controllers/project/Competitions.scala new file mode 100644 index 000000000..ac1a0cb74 --- /dev/null +++ b/ore/app/controllers/project/Competitions.scala @@ -0,0 +1,197 @@ +package controllers.project + +import javax.inject.Inject + +import scala.concurrent.ExecutionContext + +import play.api.i18n.MessagesApi +import play.api.libs.Files +import play.api.libs.json.Json +import play.api.mvc.{Action, ActionBuilder, AnyContent, MultipartFormData} + +import controllers.OreBaseController +import controllers.sugar.{Bakery, Requests} +import db.impl.query.CompetitionQueries +import form.OreForms +import form.project.competition.{CompetitionCreateForm, CompetitionSaveForm} +import ore.db.access.ModelView +import ore.db.impl.OrePostgresDriver.api._ +import ore.db.{DbRef, Model, ModelService} +import ore.models.competition.Competition +import ore.models.project.Project +import ore.permission.Permission +import ore.util.StringUtils +import ore.{OreConfig, OreEnv} +import security.spauth.{SingleSignOnConsumer, SpongeAuthApi} +import views.{html => views} + +import cats.effect.IO +import cats.syntax.all._ + +/** + * Handles competition based actions. + */ +class Competitions @Inject()(forms: OreForms)( + implicit val ec: ExecutionContext, + env: OreEnv, + messagesApi: MessagesApi, + config: OreConfig, + service: ModelService[IO], + bakery: Bakery, + auth: SpongeAuthApi, + sso: SingleSignOnConsumer +) extends OreBaseController { + identity(messagesApi) + + private val self = routes.Competitions + + private def EditCompetitionsAction: ActionBuilder[Requests.AuthRequest, AnyContent] = + Authenticated.andThen(PermissionAction(Permission.EditCompetition)) + + /** + * Shows the competition administrative panel. + * + * @return Competition manager + */ + def showManager(): Action[AnyContent] = EditCompetitionsAction.asyncF { implicit request => + service + .runDBIO(ModelView.raw(Competition).sortBy(_.createdAt).result) + .map(all => Ok(views.projects.competitions.manage(all))) + } + + /** + * Shows the competition creator. + * + * @return Competition creator + */ + def showCreator(): Action[AnyContent] = EditCompetitionsAction { implicit request => + Ok(views.projects.competitions.create()) + } + + /** + * Creates a new competition. + * + * @return Redirect to manager or creator with errors. + */ + def create(): Action[CompetitionCreateForm] = + EditCompetitionsAction(parse.form(forms.CompetitionCreate, onErrors = FormError(self.showCreator()))).asyncF { + implicit request => + service + .runDBIO( + ModelView.raw(Competition).filter(StringUtils.equalsIgnoreCase(_.name, request.body.name)).exists.result + ) + .ifM( + IO.pure(Redirect(self.showCreator()).withError("error.unique.competition.name")), + service.insert(request.body.create(request.user)).as(Redirect(self.showManager())) + ) + } + + /** + * Saves the competition with the specified ID. + * + * @param id Competition ID + * @return Redirect to manager + */ + def save(id: DbRef[Competition]): Action[CompetitionSaveForm] = + EditCompetitionsAction + .andThen(AuthedCompetitionAction(id))(parse.form(forms.CompetitionSave, onErrors = FormError(self.showManager()))) + .asyncEitherT { implicit request => + ModelView.now(Competition).get(id).toRight(notFound).semiflatMap { competition => + request.body.save(competition).as(Redirect(self.showManager()).withSuccess("success.saved.competition")) + } + } + + /** + * Deletes the competition with the specified ID. + * + * @param id Competition ID + * @return Redirect to manager + */ + def delete(id: DbRef[Competition]): Action[AnyContent] = + EditCompetitionsAction.andThen(AuthedCompetitionAction(id)).asyncF { + service + .deleteWhere(Competition)(_.id === id) + .as(Redirect(self.showManager()).withSuccess("success.deleted.competition")) + } + + /** + * Sets the specified competition's banner image. + * + * @param id Competition ID + * @return Json response + */ + def setBanner(id: DbRef[Competition]): Action[MultipartFormData[Files.TemporaryFile]] = + EditCompetitionsAction.andThen(AuthedCompetitionAction(id))(parse.multipartFormData) { implicit request => + request.body.file("banner") match { + case None => + Ok(Json.obj("error" -> request.messages.apply("error.noFile"))) + case Some(file) => + this.competitions.saveBanner(request.competition, file.ref.path.toFile, file.filename) + Ok(Json.obj("bannerUrl" -> self.showBanner(id).path())) + } + } + + /** + * Displays the specified competition's banner image, if any, NotFound + * otherwise. + * + * @param id Competition ID + * @return Banner image + */ + def showBanner(id: DbRef[Competition]): Action[AnyContent] = CompetitionAction(id) { implicit request => + this.competitions.getBannerPath(request.competition).map(showImage).getOrElse(notFound) + } + + /** + * Displays the project entries in the specified competition. + * + * @param id Competition ID + * @return List of project entries + */ + def showProjects(id: DbRef[Competition], page: Option[Int]): Action[AnyContent] = CompetitionAction(id).asyncF { + implicit request => + val userProjectsF = + request.currentUser.map(u => service.runDBIO(u.projects(ModelView.raw(Project)).result)).getOrElse(IO.pure(Nil)) + + val projectModelsF = service.runDbCon(CompetitionQueries.getEntries(id).to[Vector]) + + (userProjectsF, projectModelsF).parMapN { + case (userProjects, competitionEntries) => + Ok( + views.projects.competitions + .projects( + request.competition, + competitionEntries, + userProjects, + page.getOrElse(1), + config.ore.projects.initLoad + ) + ) + } + } + + /** + * Submits a project to the specified competition. + * + * @param id Competition ID + * @return Redirect to project list + */ + def submitProject(id: DbRef[Competition]): Action[DbRef[Project]] = + AuthedCompetitionAction(id)( + parse.form(forms.CompetitionSubmitProject, onErrors = FormError(self.showProjects(id, None))) + ).asyncEitherT { implicit request => + val projectId = request.body + request.user + .projects(ModelView.now(Project)) + .get(projectId) + .toRight(Redirect(self.showProjects(id, None)).withError("error.competition.submit.invalidProject")) + .semiflatMap(project => project.settings.tupleLeft(project)) + .flatMap { + case (project, projectSettings) => + this.competitions + .submitProject(project, projectSettings, request.competition) + .leftMap(error => Redirect(self.showProjects(id, None)).withErrors(error.toList)) + } + .as(Redirect(self.showProjects(id, None))) + } +} diff --git a/ore/app/controllers/project/Projects.scala b/ore/app/controllers/project/Projects.scala index 46e32bb14..b1b4b4183 100644 --- a/ore/app/controllers/project/Projects.scala +++ b/ore/app/controllers/project/Projects.scala @@ -7,7 +7,6 @@ import javax.inject.Inject import scala.collection.JavaConverters._ import scala.concurrent.ExecutionContext -import scala.concurrent.duration._ import play.api.cache.AsyncCacheApi import play.api.i18n.MessagesApi @@ -340,14 +339,6 @@ class Projects @Inject()(stats: StatTracker, forms: OreForms, factory: ProjectFa .getOrElse(NotFound) } - private def showImage(path: Path) = { - val lastModified = Files.getLastModifiedTime(path).toString.getBytes("UTF-8") - val lastModifiedHash = MessageDigest.getInstance("MD5").digest(lastModified) - val hashString = Base64.getEncoder.encodeToString(lastModifiedHash) - Ok.sendPath(path) - .withHeaders(ETAG -> s""""$hashString"""", CACHE_CONTROL -> s"max-age=${1.hour.toSeconds.toString}") - } - /** * Submits a flag on the specified project for further review. * @@ -490,11 +481,9 @@ class Projects @Inject()(stats: StatTracker, forms: OreForms, factory: ProjectFa */ def showSettings(author: String, slug: String): Action[AnyContent] = SettingsEditAction(author, slug).asyncF { implicit request => - request.project - .apiKeys(ModelView.now(ProjectApiKey)) - .one - .value - .map(deployKey => Ok(views.settings(request.data, request.scoped, deployKey))) + request.project.apiKeys(ModelView.now(ProjectApiKey)).one.value.map { deployKey => + Ok(views.settings(request.data, request.scoped, deployKey, request.headerData.activeCompetitions)) + } } /** @@ -589,31 +578,26 @@ class Projects @Inject()(stats: StatTracker, forms: OreForms, factory: ProjectFa * @param slug Project slug * @return View of project */ - def save(author: String, slug: String): Action[AnyContent] = SettingsEditAction(author, slug).asyncF { + def save(author: String, slug: String): Action[AnyContent] = SettingsEditAction(author, slug).asyncEitherT { implicit request => - orgasUserCanUploadTo(request.user).flatMap { organisationUserCanUploadTo => - val data = request.data - this.forms + for { + organisationUserCanUploadTo <- EitherT.right[Result](orgasUserCanUploadTo(request.user)) + formData <- forms .ProjectSave(organisationUserCanUploadTo.toSeq) - .bindFromRequest() - .fold( - FormErrorLocalized(self.showSettings(author, slug)).andThen(IO.pure), - formData => { - formData - .save(data.settings, data.project, MDCLogger) - .productR { - UserActionLogger.log( - request.request, - LoggedAction.ProjectSettingsChanged, - request.data.project.id, - "", - "" - ) //todo add old new data - } - .as(Redirect(self.show(author, slug))) - } - ) - } + .bindEitherT[IO](FormErrorLocalized(self.showSettings(author, slug))) + _ <- formData + .save(request.data.settings, request.project, MDCLogger) + .leftMap(errs => Redirect(self.showSettings(author, slug)).withErrors(errs.toList)) + _ <- EitherT.right[Result]( + UserActionLogger.log( + request.request, + LoggedAction.ProjectSettingsChanged, + request.data.project.id.value, + "", + "" + ) //todo add old new data + ) + } yield Redirect(self.show(author, slug)) } /** diff --git a/ore/app/db/impl/query/CompetitionQueries.scala b/ore/app/db/impl/query/CompetitionQueries.scala new file mode 100644 index 000000000..9042adb0d --- /dev/null +++ b/ore/app/db/impl/query/CompetitionQueries.scala @@ -0,0 +1,42 @@ +package db.impl.query + +import models.querymodels._ +import ore.db.DbRef +import ore.models.competition.Competition + +import doobie._ +import doobie.implicits._ + +object CompetitionQueries extends WebDoobieOreProtocol { + + def getEntries(competitionId: DbRef[Competition]): Query0[ProjectListEntry] = { + sql"""|SELECT p.owner_name, + | p.slug, + | p.visibility, + | p.views, + | p.downloads, + | p.stars, + | p.category, + | p.description, + | p.name, + | pv.version_string, + | array_remove(array_agg(pvt.name), NULL) AS tag_names, + | array_remove(array_agg(pvt.data), NULL) AS tag_datas, + | array_remove(array_agg(pvt.color), NULL) AS tag_colors + | FROM project_competition_entries pce + | JOIN projects p ON pce.project_id = p.id + | LEFT JOIN project_versions pv ON p.recommended_version_id = pv.id + | LEFT JOIN project_version_tags pvt ON pv.id = pvt.version_id + | WHERE pce.competition_id = $competitionId + | GROUP BY (p.owner_name, + | p.slug, + | p.visibility, + | p.views, + | p.downloads, + | p.stars, + | p.category, + | p.description, + | p.name, + | pv.version_string) ORDER BY p.name""".stripMargin.query[ProjectListEntry] + } +} diff --git a/ore/app/form/OreForms.scala b/ore/app/form/OreForms.scala index 3ea7657a9..ddd35c144 100644 --- a/ore/app/form/OreForms.scala +++ b/ore/app/form/OreForms.scala @@ -12,9 +12,11 @@ import play.api.data.{FieldMapping, Form, FormError, Mapping} import controllers.sugar.Requests.ProjectRequest import ore.db.impl.OrePostgresDriver.api._ +import form.project.competition.{CompetitionCreateForm, CompetitionSaveForm} import form.organization.{OrganizationAvatarUpdate, OrganizationMembersUpdate, OrganizationRoleSetBuilder} import form.project._ -import ore.models.project.{Channel, Page} +import ore.models.competition.Competition +import ore.models.project.{Channel, Page, Project} import ore.models.user.role.ProjectUserRole import ore.OreConfig import ore.db.access.ModelView @@ -102,6 +104,7 @@ class OreForms @Inject()(implicit config: OreConfig, factory: ProjectFactory, se "roleUps" -> list(text), "update-icon" -> boolean, "owner" -> optional(longNumber).verifying(ownerIdInList(organisationUserCanUploadTo)), + "competition" -> optional(longNumber.transform[DbRef[Competition]](i => i, i => i)), "forum-sync" -> boolean )(ProjectSettingsForm.apply)(ProjectSettingsForm.unapply) ) @@ -222,6 +225,58 @@ class OreForms @Inject()(implicit config: OreConfig, factory: ProjectFactory, se )(VersionData.apply)(VersionData.unapply) ) + private val dateFormat = this.config.ore.competitions.dateFormat + + /** + * Submits a new [[Competition]]. + */ + lazy val CompetitionCreate = Form( + mapping( + "name" -> nonEmptyText(0, this.config.ore.competitions.nameMaxLen), + "description" -> optional(nonEmptyText), + "start-date" -> localDateTime(this.dateFormat), + "end-date" -> localDateTime(this.dateFormat), + "time-zone" -> nonEmptyText, + "enable-voting" -> default(boolean, false), + "staff-only" -> default(boolean, false), + "show-vote-count" -> default(boolean, false), + "sponge-only" -> default(boolean, false), + "source-required" -> default(boolean, false), + "default-votes" -> default(number(0), 1), + "staff-votes" -> default(number(0), 1), + "default-entries" -> default(number(1), 1), + "max-entries-total" -> default(number(-1), -1) + )(CompetitionCreateForm.apply)(CompetitionCreateForm.unapply) + .verifying("error.competition.dates", _.checkDates(checkStart = true)) + ) + + /** + * Saves a Competition. + */ + lazy val CompetitionSave = Form( + mapping( + "start-date" -> localDateTime(this.dateFormat), + "end-date" -> localDateTime(this.dateFormat), + "time-zone" -> nonEmptyText, + "enable-voting" -> default(boolean, false), + "staff-only" -> default(boolean, false), + "show-vote-count" -> default(boolean, false), + "source-required" -> default(boolean, false), + "default-votes" -> default(number(0), 1), + "staff-votes" -> default(number(0), 1), + "default-entries" -> default(number(1), 1), + "max-entries-total" -> default(number(-1), -1) + )(CompetitionSaveForm.apply)(CompetitionSaveForm.unapply) + .verifying("error.competition.dates", _.checkDates(checkStart = false)) + ) + + /** + * Submits a project to a competition. + */ + lazy val CompetitionSubmitProject = Form( + single("project" -> longNumber(min = -1).transform[DbRef[Project]](i => i, i => i)) + ) + /** * Submits a change to a Version's description. */ diff --git a/ore/app/form/project/ProjectSettingsForm.scala b/ore/app/form/project/ProjectSettingsForm.scala index 07ba9cdea..8484dde12 100644 --- a/ore/app/form/project/ProjectSettingsForm.scala +++ b/ore/app/form/project/ProjectSettingsForm.scala @@ -3,12 +3,15 @@ package form.project import java.nio.file.Files import java.nio.file.Files.{createDirectories, delete, list, move, notExists} +import db.impl.access.CompetitionBase import ore.data.project.Category import ore.data.user.notification.NotificationType +import ore.db.access.ModelView import ore.models.user.{Notification, User} import ore.db.{DbRef, Model, ModelService} import ore.db.impl.schema.{ProjectRoleTable, UserTable} import ore.db.impl.OrePostgresDriver.api._ +import ore.models.competition.Competition import ore.models.project.{Project, ProjectSettings} import ore.models.project.factory.PendingProject import ore.models.project.io.ProjectFiles @@ -17,7 +20,7 @@ import ore.util.OreMDC import ore.util.StringUtils.noneIfEmpty import util.syntax._ -import cats.data.NonEmptyList +import cats.data.{EitherT, NonEmptyList, OptionT} import cats.effect.{ContextShift, IO} import cats.syntax.all._ import com.typesafe.scalalogging.LoggerTakingImplicit @@ -40,6 +43,7 @@ case class ProjectSettingsForm( roleUps: List[String], updateIcon: Boolean, ownerId: Option[DbRef[User]], + competitionId: Option[DbRef[Competition]], forumSync: Boolean ) extends TProjectRoleSetBuilder { @@ -94,7 +98,7 @@ case class ProjectSettingsForm( mdc: OreMDC, service: ModelService[IO], cs: ContextShift[IO] - ): IO[(Model[Project], Model[ProjectSettings])] = { + ): EitherT[IO, NonEmptyList[String], (Model[Project], Model[ProjectSettings])] = EitherT { import cats.instances.vector._ logger.debug("Saving project settings") logger.debug(this.toString) @@ -125,61 +129,71 @@ case class ProjectSettingsForm( val modelUpdates = (updateProject, updateSettings).parTupled - modelUpdates.flatMap { t => - // Update icon - if (this.updateIcon) { - fileManager.getPendingIconPath(project).foreach { pendingPath => - val iconDir = fileManager.getIconDir(project.ownerName, project.name) - if (notExists(iconDir)) - createDirectories(iconDir) - list(iconDir).forEach(Files.delete(_)) - move(pendingPath, iconDir.resolve(pendingPath.getFileName)) + modelUpdates.flatMap { + case t @ (newProject, newProjectSettings) => + // Update icon + if (this.updateIcon) { + fileManager.getPendingIconPath(newProject).foreach { pendingPath => + val iconDir = fileManager.getIconDir(newProject.ownerName, newProject.name) + if (notExists(iconDir)) + createDirectories(iconDir) + list(iconDir).forEach(Files.delete(_)) + move(pendingPath, iconDir.resolve(pendingPath.getFileName)) + } } - } - // Add new roles - val dossier = project.memberships - this - .build() - .toVector - .parTraverse { role => - dossier.addRole(project)(role.userId, role.copy(projectId = project.id)) - } - .flatMap { roles => - val notifications = roles.map { role => - Notification( - userId = role.userId, - originId = Some(project.ownerId), - notificationType = NotificationType.ProjectInvite, - messageArgs = NonEmptyList.of("notification.project.invite", role.role.title, project.name) - ) + // Add new roles + val dossier = newProject.memberships + this + .build() + .toVector + .parTraverse { role => + dossier.addRole(newProject)(role.userId, role.copy(projectId = newProject.id)) } + .flatMap { roles => + val notifications = roles.map { role => + Notification( + userId = role.userId, + originId = Some(newProject.ownerId), + notificationType = NotificationType.ProjectInvite, + messageArgs = NonEmptyList.of("notification.project.invite", role.role.title, newProject.name) + ) + } - service.bulkInsert(notifications) - } - .productR { - // Update existing roles - val usersTable = TableQuery[UserTable] - // Select member userIds - service - .runDBIO(usersTable.filter(_.name.inSetBind(this.userUps)).map(_.id).result) - .flatMap { userIds => - import cats.instances.list._ - val roles = this.roleUps.traverse { role => - Role.projectRoles - .find(_.value == role) - .fold(IO.raiseError[Role](new RuntimeException("supplied invalid role type")))(IO.pure) + service.bulkInsert(notifications) + } + .productR { + // Update existing roles + val usersTable = TableQuery[UserTable] + // Select member userIds + service + .runDBIO(usersTable.filter(_.name.inSetBind(this.userUps)).map(_.id).result) + .flatMap { userIds => + import cats.instances.list._ + val roles = this.roleUps.traverse { role => + Role.projectRoles + .find(_.value == role) + .fold(IO.raiseError[Role](new RuntimeException("supplied invalid role type")))(IO.pure) + } + + roles.map(xs => userIds.zip(xs)) } - - roles.map(xs => userIds.zip(xs)) - } - .map { - _.map { - case (userId, role) => updateMemberShip(userId).update(role) + .map { + _.map { + case (userId, role) => updateMemberShip(userId).update(role) + } } - } - .flatMap(updates => service.runDBIO(DBIO.sequence(updates)).as(t)) - } + .flatMap(updates => service.runDBIO(DBIO.sequence(updates)).as(t)) + } + .productR { + OptionT + .fromOption[IO](competitionId) + .flatMap(ModelView.now(Competition).get(_)) + .toRight(NonEmptyList.one("error.competition.submit.invalidProject")) + .flatMap(comp => CompetitionBase().submitProject(newProject, newProjectSettings, comp)) + .as(t) + .value + } } } diff --git a/ore/app/form/project/competition/CompetitionCreateForm.scala b/ore/app/form/project/competition/CompetitionCreateForm.scala new file mode 100644 index 000000000..b4f301d4a --- /dev/null +++ b/ore/app/form/project/competition/CompetitionCreateForm.scala @@ -0,0 +1,48 @@ +package form.project.competition + +import java.time.LocalDateTime + +import ore.db.Model +import ore.models.competition.Competition +import ore.models.user.User + +import ore.util.StringUtils._ +import ore.util.StringLocaleFormatterUtils._ + +case class CompetitionCreateForm( + name: String, + description: Option[String], + startDate: LocalDateTime, + endDate: LocalDateTime, + timeZoneId: String, + isVotingEnabled: Boolean, + isStaffVotingOnly: Boolean, + shouldShowVoteCount: Boolean, + isSpongeOnly: Boolean, + isSourceRequired: Boolean, + defaultVotes: Int, + staffVotes: Int, + allowedEntries: Int, + maxEntryTotal: Int +) extends CompetitionData { + + def create(user: Model[User]): Competition = { + Competition( + userId = user.id.value, + name = name.trim, + description = description.flatMap(noneIfEmpty), + startDate = localDateTime2Instant(startDate, timeZoneId), + endDate = localDateTime2Instant(endDate, timeZoneId), + timeZone = timeZoneId, + isVotingEnabled = isVotingEnabled, + isStaffVotingOnly = isStaffVotingOnly, + shouldShowVoteCount = shouldShowVoteCount, + isSpongeOnly = isSpongeOnly, + isSourceRequired = isSourceRequired, + defaultVotes = defaultVotes, + staffVotes = staffVotes, + allowedEntries = allowedEntries, + maxEntryTotal = Some(maxEntryTotal).filter(_ != -1) + ) + } +} diff --git a/ore/app/form/project/competition/CompetitionData.scala b/ore/app/form/project/competition/CompetitionData.scala new file mode 100644 index 000000000..aa1984ca2 --- /dev/null +++ b/ore/app/form/project/competition/CompetitionData.scala @@ -0,0 +1,23 @@ +package form.project.competition + +import java.time.{LocalDateTime, ZoneId} + +trait CompetitionData { + + def startDate: LocalDateTime + def endDate: LocalDateTime + def timeZoneId: String + def isVotingEnabled: Boolean + def isStaffVotingOnly: Boolean + def shouldShowVoteCount: Boolean + def isSourceRequired: Boolean + def defaultVotes: Int + def staffVotes: Int + def allowedEntries: Int + def maxEntryTotal: Int + def timeZone: ZoneId = ZoneId.of(this.timeZoneId) + + def checkDates(checkStart: Boolean): Boolean = + (startDate.isAfter(LocalDateTime.now(this.timeZone)) || !checkStart) && startDate.isBefore(this.endDate) + +} diff --git a/ore/app/form/project/competition/CompetitionSaveForm.scala b/ore/app/form/project/competition/CompetitionSaveForm.scala new file mode 100644 index 000000000..63bd5bd0d --- /dev/null +++ b/ore/app/form/project/competition/CompetitionSaveForm.scala @@ -0,0 +1,40 @@ +package form.project.competition + +import scala.language.higherKinds + +import java.time.LocalDateTime + +import ore.db.{Model, ModelService} +import ore.models.competition.Competition +import ore.util.StringLocaleFormatterUtils._ + +case class CompetitionSaveForm( + startDate: LocalDateTime, + endDate: LocalDateTime, + timeZoneId: String, + isVotingEnabled: Boolean, + isStaffVotingOnly: Boolean, + shouldShowVoteCount: Boolean, + isSourceRequired: Boolean, + defaultVotes: Int, + staffVotes: Int, + allowedEntries: Int, + maxEntryTotal: Int +) extends CompetitionData { + + def save[F[_]](competition: Model[Competition])(implicit service: ModelService[F]): F[Model[Competition]] = + service.update(competition)( + _.copy( + startDate = localDateTime2Instant(startDate, timeZoneId), + endDate = localDateTime2Instant(endDate, timeZoneId), + isVotingEnabled = isVotingEnabled, + isStaffVotingOnly = isStaffVotingOnly, + shouldShowVoteCount = shouldShowVoteCount, + isSourceRequired = isSourceRequired, + defaultVotes = defaultVotes, + staffVotes = staffVotes, + allowedEntries = allowedEntries, + maxEntryTotal = Some(maxEntryTotal).filter(_ != -1) + ) + ) +} diff --git a/ore/app/util/StringFormatterUtils.scala b/ore/app/util/StringFormatterUtils.scala index 2861b397e..debeea452 100644 --- a/ore/app/util/StringFormatterUtils.scala +++ b/ore/app/util/StringFormatterUtils.scala @@ -1,6 +1,6 @@ package util -import java.time.Instant +import java.time.{Instant, LocalDateTime} import play.api.i18n.Messages @@ -25,4 +25,7 @@ object StringFormatterUtils { */ def prettifyDateAndTime(instant: Instant)(implicit messages: Messages): String = StringLocaleFormatterUtils.prettifyDateAndTime(instant)(messages.lang.locale) + + def localDateTime2Instant(date: LocalDateTime, timeZone: String): Instant = + StringLocaleFormatterUtils.localDateTime2Instant(date, timeZone) } diff --git a/ore/app/views/home.scala.html b/ore/app/views/home.scala.html index 20bf07c1a..3e76a6ef6 100644 --- a/ore/app/views/home.scala.html +++ b/ore/app/views/home.scala.html @@ -102,7 +102,7 @@ -
@messages("project.competition.active.none")
+ + @messages("project.competition.active.create") + +| + + | ++ + | +
| + + | ++ + | +
| + + | ++ + | +
| + + | ++ + | +
| + + | ++ + | +
| + + | ++ + | +
| + + | ++ + | +
| + + | ++ + | +
@messages("competition.entries.empty")
+ @messages("competition.entries.empty.new") +@messages("project.competitions.info")
+