MiniDigger/Hangar

View on GitHub
ore/app/controllers/Reviews.scala

Summary

Maintainability
A
1 hr
Test Coverage
package controllers

import java.time.Instant
import javax.inject.{Inject, Singleton}

import play.api.mvc.{Action, AnyContent, Result}

import controllers.sugar.Requests.AuthRequest
import form.OreForms
import ore.data.user.notification.NotificationType
import ore.db.access.ModelView
import ore.db.impl.OrePostgresDriver.api._
import ore.db.impl.schema.{OrganizationMembersTable, OrganizationRoleTable, OrganizationTable, UserTable}
import ore.db.{DbRef, Model}
import ore.markdown.MarkdownRenderer
import ore.models.admin.{Message, Review}
import ore.models.project.{Project, ReviewState, Version}
import ore.models.user.{LoggedAction, Notification, User}
import ore.permission.Permission
import ore.permission.role.Role
import util.UserActionLogger
import views.{html => views}

import cats.data.{EitherT, NonEmptyList}
import cats.effect.IO
import cats.instances.option._
import cats.syntax.all._
import io.circe.Json
import slick.lifted.{Rep, TableQuery}

/**
  * Controller for handling Review related actions.
  */
@Singleton
final class Reviews @Inject()(forms: OreForms)(
    implicit oreComponents: OreControllerComponents[IO],
    renderer: MarkdownRenderer
) extends OreBaseController {

  def showReviews(author: String, slug: String, versionString: String): Action[AnyContent] =
    Authenticated.andThen(PermissionAction(Permission.Reviewer)).andThen(ProjectAction(author, slug)).asyncEitherT {
      implicit request =>
        for {
          version <- getVersion(request.project, versionString)
          dbio = version
            .mostRecentReviews(ModelView.raw(Review))
            .joinLeft(TableQuery[UserTable])
            .on(_.userId === _.id)
            .map(t => t._1 -> t._2.map(_.name))
            .result
          rv <- EitherT.right[Result](service.runDBIO(dbio))
        } yield {
          val unfinished = rv.map(_._1).filter(_.endedAt.isEmpty).sorted(Review.ordering2).headOption
          Ok(views.users.admin.reviews(Model.unwrapNested(unfinished), rv, request.project, version))
        }
    }

  def createReview(author: String, slug: String, versionString: String): Action[AnyContent] = {
    Authenticated.andThen(PermissionAction(Permission.Reviewer)).asyncEitherT { implicit request =>
      getProjectVersion(author, slug, versionString).semiflatMap { version =>
        val review = Review(
          version.id,
          request.user.id,
          None,
          Json.obj()
        )
        this.service.insert(review).as(Redirect(routes.Reviews.showReviews(author, slug, versionString)))
      }
    }
  }

  def reopenReview(author: String, slug: String, versionString: String): Action[AnyContent] = {
    Authenticated.andThen(PermissionAction(Permission.Reviewer)).asyncEitherT { implicit request =>
      for {
        version <- getProjectVersion(author, slug, versionString)
        review  <- version.mostRecentReviews(ModelView.now(Review)).one.toRight(notFound)
        _ <- EitherT.right[Result](
          service.update(version)(
            _.copy(
              reviewState = ReviewState.Unreviewed,
              approvedAt = None,
              reviewerId = None
            )
          )
        )
        _ <- EitherT.right[Result](
          service
            .update(review)(_.copy(endedAt = None))
            .flatMap(_.addMessage(Message("Reopened the review", System.currentTimeMillis(), "start")))
        )
      } yield Redirect(routes.Reviews.showReviews(author, slug, versionString))
    }
  }

  def stopReview(author: String, slug: String, versionString: String): Action[String] = {
    Authenticated
      .andThen(PermissionAction(Permission.Reviewer))
      .asyncEitherT(parse.form(forms.ReviewDescription)) { implicit request =>
        for {
          version <- getProjectVersion(author, slug, versionString)
          review  <- version.mostRecentUnfinishedReview(ModelView.now(Review)).toRight(notFound)
          _ <- EitherT.right[Result](
            service
              .update(review)(_.copy(endedAt = Some(Instant.now())))
              .flatMap(_.addMessage(Message(request.body.trim, System.currentTimeMillis(), "stop")))
          )
        } yield Redirect(routes.Reviews.showReviews(author, slug, versionString))
      }
  }

  def approveReview(author: String, slug: String, versionString: String): Action[AnyContent] = {
    Authenticated.andThen(PermissionAction(Permission.Reviewer)).asyncEitherT { implicit request =>
      for {
        project <- getProject(author, slug)
        version <- getVersion(project, versionString)
        review  <- version.mostRecentUnfinishedReview(ModelView.now(Review)).toRight(notFound)
        _ <- EitherT.right[Result](
          (
            service.update(review)(_.copy(endedAt = Some(Instant.now()))),
            // send notification that review happened
            sendReviewNotification(project, version, request.user)
          ).parTupled
        )
      } yield Redirect(routes.Reviews.showReviews(author, slug, versionString))
    }
  }

  private def queryNotificationUsers(
      projectId: Rep[DbRef[Project]],
      userId: Rep[DbRef[User]]
  ): Query[(Rep[DbRef[User]], Rep[Option[Role]]), (DbRef[User], Option[Role]), Seq] = {
    // Query Orga Members
    val q1 = for {
      org     <- TableQuery[OrganizationTable] if org.id === projectId
      members <- TableQuery[OrganizationMembersTable] if org.id === members.organizationId
      roles   <- TableQuery[OrganizationRoleTable] if members.userId === roles.userId // TODO roletype lvl in database?
      users   <- TableQuery[UserTable] if members.userId === users.id
    } yield (users.id, roles.roleType.?)

    // Query version author
    val q2 = for {
      user <- TableQuery[UserTable] if user.id === userId
    } yield (user.id, None: Rep[Option[Role]])

    q1 ++ q2 // Union
  }

  private lazy val notificationUsersQuery = Compiled(queryNotificationUsers _)

  private def sendReviewNotification(
      project: Model[Project],
      version: Version,
      requestUser: Model[User]
  ): IO[Unit] = {
    val usersF =
      service.runDBIO(notificationUsersQuery((project.id, version.authorId)).result).map { list =>
        list.collect {
          case (res, Some(role)) if role.permissions.has(Permission.EditVersion) => res
          case (res, None)                                                       => res
        }
      }

    usersF
      .map { users =>
        users.map { userId =>
          Notification(
            userId = userId,
            notificationType = NotificationType.VersionReviewed,
            messageArgs = NonEmptyList.of("notification.project.reviewed", project.slug, version.versionString)
          )
        }
      }
      .flatMap(service.bulkInsert(_))
      .void
  }

  def takeoverReview(author: String, slug: String, versionString: String): Action[String] = {
    Authenticated
      .andThen(PermissionAction(Permission.Reviewer))
      .asyncEitherT(parse.form(forms.ReviewDescription)) { implicit request =>
        for {
          version <- getProjectVersion(author, slug, versionString)
          _ <- {
            // Close old review
            val closeOldReview = version
              .mostRecentUnfinishedReview(ModelView.now(Review))
              .semiflatMap { oldreview =>
                (
                  oldreview.addMessage(Message(request.body.trim, System.currentTimeMillis(), "takeover")),
                  service.update(oldreview)(_.copy(endedAt = Some(Instant.now()))),
                ).parTupled.void
              }
              .getOrElse(())

            // Then make new one
            val result = (
              closeOldReview,
              this.service.insert(
                Review(
                  version.id,
                  request.user.id,
                  None,
                  Json.obj()
                )
              )
            ).parTupled
            EitherT.right[Result](result)
          }
        } yield Redirect(routes.Reviews.showReviews(author, slug, versionString))
      }
  }

  def editReview(author: String, slug: String, versionString: String, reviewId: DbRef[Review]): Action[String] = {
    Authenticated
      .andThen(PermissionAction(Permission.Reviewer))
      .asyncEitherT(parse.form(forms.ReviewDescription)) { implicit request =>
        for {
          version <- getProjectVersion(author, slug, versionString)
          review  <- version.reviewById(reviewId).toRight(notFound)
          _       <- EitherT.right[Result](review.addMessage(Message(request.body.trim)))
        } yield Ok("Review" + review)
      }
  }

  def addMessage(author: String, slug: String, versionString: String): Action[String] = {
    Authenticated.andThen(PermissionAction(Permission.Reviewer)).asyncEitherT(parse.form(forms.ReviewDescription)) {
      implicit request =>
        for {
          version      <- getProjectVersion(author, slug, versionString)
          recentReview <- version.mostRecentUnfinishedReview(ModelView.now(Review)).toRight(Ok("Review"))
          currentUser  <- users.current.toRight(Ok("Review"))
          _ <- {
            if (recentReview.userId == currentUser.id.value) {
              EitherT.right[Result](recentReview.addMessage(Message(request.body.trim)))
            } else EitherT.rightT[IO, Result](0)
          }
        } yield Ok("Review")
    }
  }

  def backlogToggle(author: String, slug: String, versionString: String): Action[AnyContent] = {
    Authenticated.andThen(PermissionAction[AuthRequest](Permission.Reviewer)).asyncEitherT { implicit request =>
      for {
        version <- getProjectVersion(author, slug, versionString)
        oldState <- EitherT.cond[IO](
          Seq(ReviewState.Backlog, ReviewState.Unreviewed).contains(version.reviewState),
          version.reviewState,
          BadRequest("Invalid state for toggle backlog")
        )
        newState = oldState match {
          case ReviewState.Unreviewed => ReviewState.Backlog
          case ReviewState.Backlog    => ReviewState.Unreviewed
          case _                      => oldState
        }
        _ <- EitherT.right[Result](
          UserActionLogger.log(
            request,
            LoggedAction.VersionReviewStateChanged,
            version.id,
            newState.toString,
            oldState.toString,
          )
        )
        _ <- EitherT.right[Result](service.update(version)(_.copy(reviewState = newState)))
      } yield Redirect(routes.Reviews.showReviews(author, slug, versionString))
    }
  }
}