streamer approval process WIP

streamers
Thibault Duplessis 2017-12-31 09:04:29 -05:00
parent 63e0bf972d
commit 62185c316c
12 changed files with 163 additions and 64 deletions

View File

@ -52,11 +52,15 @@ object Streamer extends LilaController {
implicit val req = ctx.body
StreamerForm.userForm(s.streamer).bindFromRequest.fold(
error => BadRequest(html.streamer.edit(s, error)).fuccess,
data => api.update(s.streamer, data) inject Redirect(routes.Streamer.edit())
data => api.update(s.streamer, data, isGranted(_.Streamers)) inject Redirect(routes.Streamer.edit())
)
}
}
def approvalRequest = AuthBody { implicit ctx => me =>
api.approval.request(me) inject Redirect(routes.Streamer.edit)
}
def picture = Auth { implicit ctx => _ =>
AsStreamer { s =>
NoCache(Ok(html.streamer.picture(s))).fuccess
@ -81,7 +85,7 @@ object Streamer extends LilaController {
}
private def AsStreamer(f: StreamerModel.WithUser => Fu[Result])(implicit ctx: Context) =
ctx.me ?? api.find flatMap { _.fold(notFound)(f) }
get("u").ifTrue(isGranted(_.Streamers)).orElse(ctx.userId) ?? api.find flatMap { _.fold(notFound)(f) }
private def WithVisibleStreamer(s: StreamerModel.WithUser)(f: Fu[Result])(implicit ctx: Context) =
if (s.streamer.isListed || ctx.me.??(s.streamer.is) || isGranted(_.Admin)) f

View File

@ -30,12 +30,12 @@ side = side.some) {
<div class="top">
<div class="picture_wrap">
@if(s.streamer.hasPicture) {
<a class="upload_picture" href="@routes.Streamer.picture" title="Change/delete your profile picture">
<a class="upload_picture" href="@routes.Streamer.picture" title="Change/delete your streamer picture">
@pic(s, 250)
</a>
} else {
<div class="upload_picture">
<a class="button" href="@routes.Streamer.picture">Upload a profile picture</a>
<a class="button" href="@routes.Streamer.picture">Upload a streamer picture</a>
</div>
}
</div>
@ -44,21 +44,55 @@ side = side.some) {
@rules()
</div>
</div>
@defining(s.streamer.approval.granted) { granted =>
<div class="status is@if(granted){-green}" data-icon="@if(granted){E}else{}">
@if(granted) {
Your stream is approved and listed on <a href="@routes.Streamer.index()">lichess streamers list</a>.
} else {
@if(s.streamer.approval.requested) {
Your stream is being reviewed by moderators, and will soon be listed on <a href="@routes.Streamer.index()">lichess streamers list</a>.
} else {
@if(s.streamer.completeEnough) {
When you are ready to be listed on <a href="@routes.Streamer.index()">lichess streamers list</a>,
<form method="post" action="@routes.Streamer.approvalRequest">
<button type="submmit" class="button">request a moderator review</button>
</form>
} else {
Please fill in your streamer information below.
}
}
}
</div>
<form class="content_box_content material form" action="@routes.Streamer.edit" method="POST">
@errMsgMaterial(form.errors)
@base.form.group(form("twitch"), Html("Your Twitch username or URL"), help = Html("Optional. Leave empty if none").some) {
@base.form.input(form("twitch"))
@if(isGranted(_.Streamers)) {
@base.form.group(form("mod.approved"), Html("Publish on the streamers list"), help = Html("Moderation").some, half = true) {
@base.form.select(form("mod.approved"), booleanChoices)
}
@base.form.group(form("youTube"), Html("Your YouTube channel ID or URL"), help = Html("Optional. Leave empty if none").some) {
@base.form.input(form("youTube"))
@if(granted) {
@base.form.group(form("mod.featured"), Html("Feature on lichess homepage"), help = Html("Moderation").some, half = true) {
@base.form.select(form("mod.featured"), booleanChoices)
}
} else {
@base.form.group(form("mod.ignored"), Html("Ignore further approval requests"), help = Html("Moderation").some, half = true) {
@base.form.select(form("mod.ignored"), booleanChoices)
}
}
}
@base.form.group(form("twitch"), Html("Your Twitch username or URL"), help = Html("Optional. Leave empty if none").some) {
@base.form.input(form("twitch"))
}
@base.form.group(form("youTube"), Html("Your YouTube channel ID or URL"), help = Html("Optional. Leave empty if none. Example: youtube.com/channel/UCr6RfQga70yMM9-nuzAYTsA").some) {
@base.form.input(form("youTube"))
}
@base.form.group(form("name"), Html("Your streamer name"), help = Html("Keep it short: 20 characters max").some) {
@base.form.input(form("name"), maxLength = 20)
@base.form.input(form("name"), maxLength = 20)
}
@base.form.group(form("description"), Html("Short description"), help = Html("Who are you, why do you love streaming?").some) {
<textarea name="description" id="description" maxlength="300">@form("description").value</textarea>
<textarea name="description" id="description" maxlength="300">@form("description").value</textarea>
}
@base.form.submit()
</form>
}
</div>
}

View File

@ -4,6 +4,9 @@
<div class="streamer-side">
<div class="profile">
<a class="blue" href="@routes.User.show(s.user.username)">View @s.user.username lichess profile</a>
@if(ctx.is(s.user)) {
<br /><a class="blue" href="@routes.Streamer.edit()">Edit streamer page</a>
}
</div>
</div>
}

View File

@ -196,6 +196,7 @@ GET /streamer/new controllers.Streamer.create
POST /streamer/new controllers.Streamer.createApply
GET /streamer/edit controllers.Streamer.edit
POST /streamer/edit controllers.Streamer.editApply
POST /streamer/approval/request controllers.Streamer.approvalRequest
GET /streamer/picture/edit controllers.Streamer.picture
POST /streamer/picture/upload controllers.Streamer.pictureApply
POST /streamer/picture/delete controllers.Streamer.pictureDelete

View File

@ -9,7 +9,20 @@ object CoachProfileForm {
def edit(coach: Coach) = Form(mapping(
"listed" -> boolean,
"available" -> boolean,
"profile" -> profileMapping
"profile" -> mapping(
"headline" -> optional(nonEmptyText(minLength = 5, maxLength = 170)),
"languages" -> optional(nonEmptyText(minLength = 3, maxLength = 140)),
"hourlyRate" -> optional(nonEmptyText(minLength = 3, maxLength = 140)),
"description" -> optional(richText),
"playingExperience" -> optional(richText),
"teachingExperience" -> optional(richText),
"otherExperience" -> optional(richText),
"skills" -> optional(richText),
"methodology" -> optional(richText),
"youtubeVideos" -> optional(nonEmptyText),
"youtubeChannel" -> optional(nonEmptyText),
"publicStudies" -> optional(nonEmptyText)
)(CoachProfile.apply)(CoachProfile.unapply)
)(Data.apply)(Data.unapply)) fill Data(
listed = coach.listed.value,
available = coach.available.value,
@ -30,21 +43,6 @@ object CoachProfileForm {
)
}
private def profileMapping = mapping(
"headline" -> optional(nonEmptyText(minLength = 5, maxLength = 170)),
"languages" -> optional(nonEmptyText(minLength = 3, maxLength = 140)),
"hourlyRate" -> optional(nonEmptyText(minLength = 3, maxLength = 140)),
"description" -> optional(richText),
"playingExperience" -> optional(richText),
"teachingExperience" -> optional(richText),
"otherExperience" -> optional(richText),
"skills" -> optional(richText),
"methodology" -> optional(richText),
"youtubeVideos" -> optional(nonEmptyText),
"youtubeChannel" -> optional(nonEmptyText),
"publicStudies" -> optional(nonEmptyText)
)(CoachProfile.apply)(CoachProfile.unapply)
import CoachProfile.RichText
private implicit val richTextFormat = lila.common.Form.formatter.stringFormatter[RichText](_.value, RichText.apply)

View File

@ -51,6 +51,7 @@ object Permission {
case object Relay extends Permission("ROLE_RELAY")
case object Cli extends Permission("ROLE_ClI")
case object Settings extends Permission("ROLE_SETTINGS")
case object Streamers extends Permission("ROLE_STREAMERS")
case object Hunter extends Permission("ROLE_HUNTER", List(
ViewBlurs, MarkEngine, MarkBooster, StaffForum,
@ -63,7 +64,7 @@ object Permission {
ChatTimeout, MarkTroll, SetTitle, SetEmail, ModerateQa, StreamConfig,
MessageAnyone, CloseTeam, TerminateTournament, ManageTournament, ManageEvent,
PreviewCoach, PracticeConfig, RemoveRanking, ReportBan, Beta, DisapproveCoachReview,
Relay
Relay, Streamers
))
case object SuperAdmin extends Permission("ROLE_SUPER_ADMIN", List(
@ -75,7 +76,7 @@ object Permission {
UserSpy, MarkEngine, MarkBooster, IpBan, ModerateQa, StreamConfig, PracticeConfig,
Beta, MessageAnyone, UserSearch, CloseTeam, TerminateTournament, ManageTournament, ManageEvent,
PublicMod, Developer, Coach, PreviewCoach, ModNote, RemoveRanking, ReportBan,
Relay, Cli, Settings
Relay, Cli, Settings, Streamers
)
lazy private val all: List[Permission] = SuperAdmin :: allButSuperAdmin

View File

@ -7,17 +7,16 @@ private object BsonHandlers {
implicit val StreamerIdBSONHandler = stringAnyValHandler[Streamer.Id](_.value, Streamer.Id.apply)
implicit val StreamerListedBSONHandler = booleanAnyValHandler[Streamer.Listed](_.value, Streamer.Listed.apply)
implicit val StreamerApprovedBSONHandler = booleanAnyValHandler[Streamer.Approved](_.value, Streamer.Approved.apply)
implicit val StreamerAutoFeaturedBSONHandler = booleanAnyValHandler[Streamer.AutoFeatured](_.value, Streamer.AutoFeatured.apply)
implicit val StreamerChatEnabledBSONHandler = booleanAnyValHandler[Streamer.ChatEnabled](_.value, Streamer.ChatEnabled.apply)
implicit val StreamerPicturePathBSONHandler = stringAnyValHandler[Streamer.PicturePath](_.value, Streamer.PicturePath.apply)
implicit val StreamerNameBSONHandler = stringAnyValHandler[Streamer.Name](_.value, Streamer.Name.apply)
implicit val StreamerDescriptionBSONHandler = stringAnyValHandler[Streamer.Description](_.value, Streamer.Description.apply)
import Streamer.{ Live, Twitch, YouTube, Sorting }
import Streamer.{ Live, Twitch, YouTube, Sorting, Approval }
implicit val StreamerLiveBSONHandler = Macros.handler[Live]
implicit val StreamerTwitchBSONHandler = Macros.handler[Twitch]
implicit val StreamerYouTubeBSONHandler = Macros.handler[YouTube]
implicit val StreamerSortingBSONHandler = Macros.handler[Sorting]
implicit val StreamerApprovalBSONHandler = Macros.handler[Approval]
implicit val StreamerBSONHandler = Macros.handler[Streamer]
}

View File

@ -20,8 +20,12 @@ private final class Importer(api: StreamerApi, flagColl: Coll) {
api.save(Streamer(
_id = Id(s.lichessName.toLowerCase),
listed = Listed(true),
approved = Approved(true),
autoFeatured = AutoFeatured(s.featured),
approval = Approval(
requested = false,
granted = true,
ignored = false,
autoFeatured = s.featured
),
chatEnabled = ChatEnabled(s.chat),
picturePath = none,
name = Name {

View File

@ -7,8 +7,7 @@ import lila.user.User
case class Streamer(
_id: Streamer.Id, // user ID
listed: Streamer.Listed, // user wants to be in the list
approved: Streamer.Approved, // mods agree about being in the list
autoFeatured: Streamer.AutoFeatured, // on homepage when title contains "lichess.org"
approval: Streamer.Approval,
chatEnabled: Streamer.ChatEnabled, // embed chat inside lichess
picturePath: Option[Streamer.PicturePath],
name: Streamer.Name,
@ -26,7 +25,7 @@ case class Streamer(
def hasPicture = picturePath.isDefined
def isListed = listed.value && approved.value
def isListed = listed.value && approval.granted
def seenAt: Option[DateTime] = sorting.seenAt
def liveAt: Option[DateTime] = (twitch.flatMap(_.live.liveAt), youTube.flatMap(_.live.liveAt)) match {
@ -35,6 +34,10 @@ case class Streamer(
}
case (twitch, youTube) => twitch orElse youTube
}
def completeEnough = {
twitch.isDefined || youTube.isDefined
} && description.isDefined && hasPicture
}
object Streamer {
@ -42,8 +45,12 @@ object Streamer {
def make(user: User) = Streamer(
_id = Id(user.id),
listed = Listed(true),
approved = Approved(false),
autoFeatured = AutoFeatured(false),
approval = Approval(
requested = false,
granted = false,
ignored = false,
autoFeatured = false
),
chatEnabled = ChatEnabled(true),
picturePath = none,
name = Name(user.realNameOrUsername),
@ -57,8 +64,12 @@ object Streamer {
case class Id(value: User.ID) extends AnyVal with StringValue
case class Listed(value: Boolean) extends AnyVal
case class Approved(value: Boolean) extends AnyVal
case class AutoFeatured(value: Boolean) extends AnyVal
case class Approval(
requested: Boolean, // user requests a mod to approve
granted: Boolean, // a mod approved
ignored: Boolean, // further requests are ignored
autoFeatured: Boolean // on homepage when title contains "lichess.org"
)
case class ChatEnabled(value: Boolean) extends AnyVal
case class PicturePath(value: String) extends AnyVal with StringValue
case class Name(value: String) extends AnyVal with StringValue

View File

@ -34,13 +34,13 @@ final class StreamerApi(
coll.update($id(s.id), s, upsert = true).void
def setSeenAt(user: User): Funit =
listedIdsCache.get.pp(user.username) flatMap { ids =>
listedIdsCache.get flatMap { ids =>
ids.contains(Streamer.Id(user.id)) ??
coll.update($id(user.id), $set("sorting.seenAt" -> DateTime.now)).void
}
def update(s: Streamer, data: StreamerForm.UserData): Funit =
coll.update($id(s.id), data(s)).void
def update(s: Streamer, data: StreamerForm.UserData, asMod: Boolean): Funit =
coll.update($id(s.id), data(s, asMod)).void
def create(u: User): Funit =
isStreamer(u) flatMap { exists =>
@ -57,11 +57,20 @@ final class StreamerApi(
def deletePicture(s: Streamer): Funit =
coll.update($id(s.id), $unset("picturePath")).void
object approval {
def request(user: User) = find(user) flatMap {
_.filter(!_.streamer.approval.granted) ?? { s =>
coll.updateField($id(s.streamer.id), "approval.requested", true).void
}
}
}
private def withUser(user: User)(streamer: Streamer) = Streamer.WithUser(streamer, user)
private def selectListedApproved = $doc(
"listed" -> true,
"approved" -> true
"approval.granted" -> true
)
private val listedIdsCache = asyncCache.single[Set[Streamer.Id]](

View File

@ -12,36 +12,67 @@ object StreamerForm {
"name" -> name,
"description" -> optional(description),
"twitch" -> optional(nonEmptyText.verifying("Invalid Twitch username", s => Streamer.Twitch.parseUserId(s).isDefined)),
"youTube" -> optional(nonEmptyText.verifying("Invalid YouTube channel", s => Streamer.YouTube.parseChannelId(s).isDefined))
"youTube" -> optional(nonEmptyText.verifying("Invalid YouTube channel", s => Streamer.YouTube.parseChannelId(s).isDefined)),
"mod" -> optional(mapping(
"approved" -> boolean,
"ignored" -> boolean,
"featured" -> boolean
)(ModData.apply)(ModData.unapply))
)(UserData.apply)(UserData.unapply))
def userForm(streamer: Streamer) = emptyUserForm fill UserData(
name = streamer.name,
description = streamer.description,
twitch = streamer.twitch.map(_.userId),
youTube = streamer.youTube.map(_.channelId)
youTube = streamer.youTube.map(_.channelId),
mod = ModData(
approved = streamer.approval.granted,
ignored = streamer.approval.ignored,
featured = streamer.approval.autoFeatured
).some
)
case class UserData(
name: Name,
description: Option[Description],
twitch: Option[String],
youTube: Option[String]
youTube: Option[String],
mod: Option[ModData]
) {
def apply(streamer: Streamer) = streamer.copy(
name = name,
description = description,
twitch = twitch.flatMap(Twitch.parseUserId).fold(streamer.twitch) { userId =>
streamer.twitch.fold(Twitch(userId, Live.empty))(_.copy(userId = userId)).some
},
youTube = youTube.flatMap(YouTube.parseChannelId).fold(streamer.youTube) { channelId =>
streamer.youTube.fold(YouTube(channelId, Live.empty))(_.copy(channelId = channelId)).some
},
updatedAt = DateTime.now
)
def apply(streamer: Streamer, asMod: Boolean) = {
val newStreamer = streamer.copy(
name = name,
description = description,
twitch = twitch.flatMap(Twitch.parseUserId).fold(streamer.twitch) { userId =>
streamer.twitch.fold(Twitch(userId, Live.empty))(_.copy(userId = userId)).some
},
youTube = youTube.flatMap(YouTube.parseChannelId).fold(streamer.youTube) { channelId =>
streamer.youTube.fold(YouTube(channelId, Live.empty))(_.copy(channelId = channelId)).some
},
updatedAt = DateTime.now
)
newStreamer.copy(
approval = mod match {
case Some(m) if asMod => streamer.approval.copy(
granted = m.approved,
ignored = m.ignored && !m.approved,
autoFeatured = m.featured && m.approved
)
case None if streamer.twitch != newStreamer.twitch || streamer.youTube != newStreamer.youTube =>
streamer.approval.copy(granted = false, autoFeatured = false)
case None => streamer.approval
}
)
}
}
case class ModData(
approved: Boolean,
ignored: Boolean,
featured: Boolean
)
private implicit val descriptionFormat = lila.common.Form.formatter.stringFormatter[Description](_.value, Description.apply)
private def description = of[Description]
private implicit val nameFormat = lila.common.Form.formatter.stringFormatter[Name](_.value, Name.apply)

View File

@ -51,14 +51,18 @@ body.dark .streamer_edit .top a {
}
.streamer_edit .status {
opacity: 0;
transition: 0.5s;
text-align: center;
color: #759900;
margin: 30px 20px 20px 20px;
padding: 20px;
background: rgba(128,128,128,0.2);
}
.streamer_edit .status.saved {
opacity: 1;
.streamer_edit .status::before {
font-size: 2em;
margin-right: 0.5em;
}
.streamer_edit .status form {
display: inline;
}
#site_header a.preview {
display: block;
margin-top: 40px;