Merge branch 'improve-group'

This commit is contained in:
takezoe
2014-03-06 11:06:19 +09:00
15 changed files with 340 additions and 56 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE GROUP_MEMBER ADD COLUMN MANAGER BOOLEAN DEFAULT FALSE;

View File

@@ -20,7 +20,7 @@ class ScalatraBootstrap extends LifeCycle {
context.mount(new DashboardController, "/*")
context.mount(new UserManagementController, "/*")
context.mount(new SystemSettingsController, "/*")
context.mount(new CreateRepositoryController, "/*")
context.mount(new CreateController, "/*")
context.mount(new AccountController, "/*")
context.mount(new RepositoryViewerController, "/*")
context.mount(new WikiController, "/*")

View File

@@ -51,14 +51,20 @@ trait AccountControllerBase extends AccountManagementControllerBase {
getActivitiesByUser(userName, true))
// Members
case "members" if(account.isGroupAccount) =>
_root_.account.html.members(account, getGroupMembers(account.userName))
case "members" if(account.isGroupAccount) => {
val members = getGroupMembers(account.userName)
_root_.account.html.members(account, members.map(_._1),
context.loginAccount.exists(x => members.exists { case (userName, isManager) => userName == x.userName && isManager }))
}
// Repositories
case _ =>
case _ => {
val members = getGroupMembers(account.userName)
_root_.account.html.repositories(account,
if(account.isGroupAccount) Nil else getGroupsByUserName(userName),
getVisibleRepositories(context.loginAccount, baseUrl, Some(userName)))
getVisibleRepositories(context.loginAccount, baseUrl, Some(userName)),
context.loginAccount.exists(x => members.exists { case (userName, isManager) => userName == x.userName && isManager }))
}
}
} getOrElse NotFound
}

View File

@@ -9,35 +9,53 @@ import jp.sf.amateras.scalatra.forms._
import org.eclipse.jgit.lib.{FileMode, Constants}
import org.eclipse.jgit.dircache.DirCache
import org.scalatra.i18n.Messages
import org.apache.commons.io.FileUtils
class CreateRepositoryController extends CreateRepositoryControllerBase
class CreateController extends CreateControllerBase
with RepositoryService with AccountService with WikiService with LabelsService with ActivityService
with UsersAuthenticator with ReadableUsersAuthenticator
with UsersAuthenticator with ReadableUsersAuthenticator with GroupManagerAuthenticator
/**
* Creates new repository.
* Creates new repository or group.
*/
trait CreateRepositoryControllerBase extends ControllerBase {
trait CreateControllerBase extends AccountManagementControllerBase {
self: RepositoryService with AccountService with WikiService with LabelsService with ActivityService
with UsersAuthenticator with ReadableUsersAuthenticator =>
with UsersAuthenticator with ReadableUsersAuthenticator with GroupManagerAuthenticator =>
case class RepositoryCreationForm(owner: String, name: String, description: Option[String], isPrivate: Boolean, createReadme: Boolean)
case class ForkRepositoryForm(owner: String, name: String)
val newForm = mapping(
val newRepositoryForm = mapping(
"owner" -> trim(label("Owner" , text(required, maxlength(40), identifier, existsAccount))),
"name" -> trim(label("Repository name", text(required, maxlength(40), identifier, unique))),
"name" -> trim(label("Repository name", text(required, maxlength(40), identifier, uniqueRepository))),
"description" -> trim(label("Description" , optional(text()))),
"isPrivate" -> trim(label("Repository Type", boolean())),
"createReadme" -> trim(label("Create README" , boolean()))
)(RepositoryCreationForm.apply)
val forkForm = mapping(
val forkRepositoryForm = mapping(
"owner" -> trim(label("Repository owner", text(required))),
"name" -> trim(label("Repository name", text(required)))
)(ForkRepositoryForm.apply)
case class NewGroupForm(groupName: String, url: Option[String], fileId: Option[String], members: String)
case class EditGroupForm(groupName: String, url: Option[String], fileId: Option[String], members: String, clearImage: Boolean)
val newGroupForm = mapping(
"groupName" -> trim(label("Group name" ,text(required, maxlength(100), identifier, uniqueUserName))),
"url" -> trim(label("URL" ,optional(text(maxlength(200))))),
"fileId" -> trim(label("File ID" ,optional(text()))),
"members" -> trim(label("Members" ,text(required, members)))
)(NewGroupForm.apply)
val editGroupForm = mapping(
"groupName" -> trim(label("Group name" ,text(required, maxlength(100), identifier))),
"url" -> trim(label("URL" ,optional(text(maxlength(200))))),
"fileId" -> trim(label("File ID" ,optional(text()))),
"members" -> trim(label("Members" ,text(required, members))),
"clearImage" -> trim(label("Clear image" ,boolean()))
)(EditGroupForm.apply)
/**
* Show the new repository form.
*/
@@ -48,7 +66,7 @@ trait CreateRepositoryControllerBase extends ControllerBase {
/**
* Create new repository.
*/
post("/new", newForm)(usersOnly { form =>
post("/new", newRepositoryForm)(usersOnly { form =>
LockUtil.lock(s"${form.owner}/${form.name}/create"){
if(getRepository(form.owner, form.name, baseUrl).isEmpty){
val ownerAccount = getAccountByUserName(form.owner).get
@@ -60,7 +78,7 @@ trait CreateRepositoryControllerBase extends ControllerBase {
// Add collaborators for group repository
if(ownerAccount.isGroupAccount){
getGroupMembers(form.owner).foreach { userName =>
getGroupMembers(form.owner).foreach { case (userName, isManager) =>
addCollaborator(form.owner, form.name, userName)
}
}
@@ -172,6 +190,68 @@ trait CreateRepositoryControllerBase extends ControllerBase {
}
})
get("/groups/new")(usersOnly {
html.group(None, List((context.loginAccount.get.userName, true)))
})
post("/groups/new", newGroupForm)(usersOnly { form =>
createGroup(form.groupName, form.url)
updateGroupMembers(form.groupName, form.members.split(",").map {
_.split(":") match {
case Array(userName, isManager) => (userName, isManager.toBoolean)
}
}.toList)
updateImage(form.groupName, form.fileId, false)
redirect(s"/${form.groupName}")
})
get("/:groupName/_editgroup")(managersOnly {
defining(params("groupName")){ groupName =>
html.group(getAccountByUserName(groupName, true), getGroupMembers(groupName))
}
})
get("/:groupName/_deletegroup")(managersOnly {
defining(params("groupName")){ groupName =>
// Remove from GROUP_MEMBER
updateGroupMembers(groupName, Nil)
// Remove repositories
getRepositoryNamesOfUser(groupName).foreach { repositoryName =>
deleteRepository(groupName, repositoryName)
FileUtils.deleteDirectory(getRepositoryDir(groupName, repositoryName))
FileUtils.deleteDirectory(getWikiRepositoryDir(groupName, repositoryName))
FileUtils.deleteDirectory(getTemporaryDir(groupName, repositoryName))
}
}
redirect("/")
})
post("/:groupName/_editgroup", editGroupForm)(managersOnly { form =>
defining(params("groupName"), form.members.split(",").map {
_.split(":") match {
case Array(userName, isManager) => (userName, isManager.toBoolean)
}
}.toList){ case (groupName, members) =>
getAccountByUserName(groupName, true).map { account =>
updateGroup(groupName, form.url, false)
// Update GROUP_MEMBER
updateGroupMembers(form.groupName, members)
// Update COLLABORATOR for group repositories
getRepositoryNamesOfUser(form.groupName).foreach { repositoryName =>
removeCollaborators(form.groupName, repositoryName)
members.foreach { case (userName, isManager) =>
addCollaborator(form.groupName, repositoryName, userName)
}
}
updateImage(form.groupName, form.fileId, form.clearImage)
redirect(s"/${form.groupName}")
} getOrElse NotFound
}
})
private def insertDefaultLabels(userName: String, repositoryName: String): Unit = {
createLabel(userName, repositoryName, "bug", "fc2929")
createLabel(userName, repositoryName, "duplicate", "cccccc")
@@ -186,14 +266,20 @@ trait CreateRepositoryControllerBase extends ControllerBase {
if(getAccountByUserName(value).isEmpty) Some("User or group does not exist.") else None
}
/**
* Duplicate check for the repository name.
*/
private def unique: Constraint = new Constraint(){
private def uniqueRepository: Constraint = new Constraint(){
override def validate(name: String, value: String, params: Map[String, String], messages: Messages): Option[String] =
params.get("owner").flatMap { userName =>
getRepositoryNamesOfUser(userName).find(_ == value).map(_ => "Repository already exists.")
}
}
private def members: Constraint = new Constraint(){
override def validate(name: String, value: String, messages: Messages): Option[String] = {
if(value.split(",").exists {
_.split(":") match { case Array(userName, isManager) => isManager.toBoolean }
}) None else Some("Must select one manager at least.")
}
}
}

View File

@@ -71,7 +71,7 @@ trait UserManagementControllerBase extends AccountManagementControllerBase {
val users = getAllUsers(includeRemoved)
val members = users.collect { case account if(account.isGroupAccount) =>
account.userName -> getGroupMembers(account.userName)
account.userName -> getGroupMembers(account.userName).map(_._1)
}.toMap
admin.users.html.list(users, members, includeRemoved)
})
@@ -127,7 +127,11 @@ trait UserManagementControllerBase extends AccountManagementControllerBase {
post("/admin/users/_newgroup", newGroupForm)(adminOnly { form =>
createGroup(form.groupName, form.url)
updateGroupMembers(form.groupName, form.memberNames.map(_.split(",").toList).getOrElse(Nil))
updateGroupMembers(form.groupName, form.memberNames.map(_.split(",").map {
_.split(":") match {
case Array(userName, isManager) => (userName, isManager.toBoolean)
}
}.toList).getOrElse(Nil))
updateImage(form.groupName, form.fileId, false)
redirect("/admin/users")
})
@@ -139,7 +143,11 @@ trait UserManagementControllerBase extends AccountManagementControllerBase {
})
post("/admin/users/:groupName/_editgroup", editGroupForm)(adminOnly { form =>
defining(params("groupName"), form.memberNames.map(_.split(",").toList).getOrElse(Nil)){ case (groupName, memberNames) =>
defining(params("groupName"), form.memberNames.map(_.split(",").map {
_.split(":") match {
case Array(userName, isManager) => (userName, isManager.toBoolean)
}
}.toList).getOrElse(Nil)){ case (groupName, members) =>
getAccountByUserName(groupName, true).map { account =>
updateGroup(groupName, form.url, form.isRemoved)
@@ -155,11 +163,11 @@ trait UserManagementControllerBase extends AccountManagementControllerBase {
}
} else {
// Update GROUP_MEMBER
updateGroupMembers(form.groupName, memberNames)
updateGroupMembers(form.groupName, members)
// Update COLLABORATOR for group repositories
getRepositoryNamesOfUser(form.groupName).foreach { repositoryName =>
removeCollaborators(form.groupName, repositoryName)
memberNames.foreach { userName =>
members.foreach { case (userName, isManager) =>
addCollaborator(form.groupName, repositoryName, userName)
}
}

View File

@@ -5,10 +5,12 @@ import scala.slick.driver.H2Driver.simple._
object GroupMembers extends Table[GroupMember]("GROUP_MEMBER") {
def groupName = column[String]("GROUP_NAME", O PrimaryKey)
def userName = column[String]("USER_NAME", O PrimaryKey)
def * = groupName ~ userName <> (GroupMember, GroupMember.unapply _)
def isManager = column[Boolean]("MANAGER")
def * = groupName ~ userName ~ isManager <> (GroupMember, GroupMember.unapply _)
}
case class GroupMember(
groupName: String,
userName: String
userName: String,
isManager: Boolean
)

View File

@@ -122,18 +122,18 @@ trait AccountService {
def updateGroup(groupName: String, url: Option[String], removed: Boolean): Unit =
Accounts.filter(_.userName is groupName.bind).map(t => t.url.? ~ t.removed).update(url, removed)
def updateGroupMembers(groupName: String, members: List[String]): Unit = {
def updateGroupMembers(groupName: String, members: List[(String, Boolean)]): Unit = {
Query(GroupMembers).filter(_.groupName is groupName.bind).delete
members.foreach { userName =>
GroupMembers insert GroupMember (groupName, userName)
members.foreach { case (userName, isManager) =>
GroupMembers insert GroupMember (groupName, userName, isManager)
}
}
def getGroupMembers(groupName: String): List[String] =
def getGroupMembers(groupName: String): List[(String, Boolean)] =
Query(GroupMembers)
.filter(_.groupName is groupName.bind)
.sortBy(_.userName)
.map(_.userName)
.map(m => m.userName ~ m.isManager)
.list
def getGroupsByUserName(userName: String): List[String] =

View File

@@ -50,6 +50,7 @@ object AutoUpdate {
* The history of versions. A head of this sequence is the current BitBucket version.
*/
val versions = Seq(
Version(1, 12),
Version(1, 11),
Version(1, 10),
Version(1, 9),

View File

@@ -155,3 +155,22 @@ trait ReadableUsersAuthenticator { self: ControllerBase with RepositoryService =
}
}
}
/**
* Allows only the group managers.
*/
trait GroupManagerAuthenticator { self: ControllerBase with AccountService =>
protected def managersOnly(action: => Any) = { authenticate(action) }
protected def managersOnly[T](action: T => Any) = (form: T) => { authenticate(action(form)) }
private def authenticate(action: => Any) = {
{
defining(request.paths){ paths =>
context.loginAccount match {
case Some(x) if(getGroupMembers(paths(0)).exists { case (userName, isManager) => userName == x.userName && isManager }) => action
case _ => Unauthorized()
}
}
}
}
}

View File

@@ -1,4 +1,5 @@
@(account: model.Account, groupNames: List[String], active: String)(body: Html)(implicit context: app.Context)
@(account: model.Account, groupNames: List[String], active: String,
isGroupManager: Boolean = false)(body: Html)(implicit context: app.Context)
@import context._
@import view.helpers._
@html.main(account.userName){
@@ -41,6 +42,13 @@
</div>
</li>
}
@if(loginAccount.isDefined && account.isGroupAccount && isGroupManager){
<li class="pull-right">
<div class="button-group">
<a href="@url(account.userName)/_editgroup" class="btn">Edit Group</a>
</div>
</li>
}
</ul>
@body
</div>

View File

@@ -1,7 +1,7 @@
@(account: model.Account, members: List[String])(implicit context: app.Context)
@(account: model.Account, members: List[String], isGroupManager: Boolean)(implicit context: app.Context)
@import context._
@import view.helpers._
@main(account, Nil, "members"){
@main(account, Nil, "members", isGroupManager){
@if(members.isEmpty){
No members
} else {

View File

@@ -1,7 +1,9 @@
@(account: model.Account, groupNames: List[String], repositories: List[service.RepositoryService.RepositoryInfo])(implicit context: app.Context)
@(account: model.Account, groupNames: List[String],
repositories: List[service.RepositoryService.RepositoryInfo],
isGroupManager: Boolean)(implicit context: app.Context)
@import context._
@import view.helpers._
@main(account, groupNames, "repositories"){
@main(account, groupNames, "repositories", isGroupManager){
@if(repositories.isEmpty){
No repositories
} else {

View File

@@ -1,4 +1,4 @@
@(account: Option[model.Account], members: List[String])(implicit context: app.Context)
@(account: Option[model.Account], members: List[(String, Boolean)])(implicit context: app.Context)
@import context._
@import view.helpers._
@html.main(if(account.isEmpty) "New Group" else "Update Group"){
@@ -34,9 +34,10 @@
<div class="span5">
<fieldset>
<label class="strong">Members</label>
<ul id="members" class="collaborator">
@members.map { userName =>
<ul id="member-list" class="collaborator">
@members.map { case (userName, isManager) =>
<li data-name="@userName">
<input type="checkbox" class="is_manager" id="is_manager_@userName" @if(isManager){checked}/>
<a href="@path/@url(userName)">@userName</a>
<a href="#" class="remove">(remove)</a>
</li>
@@ -44,9 +45,9 @@
</ul>
@helper.html.account("memberName", 200)
<input type="button" class="btn" value="Add" id="addMember"/>
<input type="hidden" id="memberNames" name="memberNames" value="@members.mkString(",")"/>
<input type="hidden" id="members" name="members" value="@members.map(x => x._1 + ":" + x._2).mkString(",")"/>
<div>
<span class="error" id="error-memberName"></span>
<span class="error" id="error-members"></span>
</div>
</fieldset>
</div>
@@ -70,7 +71,7 @@ $(function(){
}
// check duplication
var exists = $('#members li').filter(function(){
var exists = $('#member-list li').filter(function(){
return $(this).data('name') == userName;
}).length > 0;
if(exists){
@@ -84,18 +85,16 @@ $(function(){
}, function(data, status){
if(data == 'true'){
// add member
$('#members').append($('<li>')
$('#member-list').append($('<li>')
.data('name', userName)
.append($('<input type="checkbox" class="is_manager">').attr('id', 'is_manager_' + userName))
.append($('<a>').attr('href', '@path/' + userName).text(userName))
.append(' ')
.append($('<a>').attr('href', '#').addClass('remove').text('(remove)')));
$('#memberName').val('');
$('#members').val('');
// update hidden value
var userNames = $('#members li').map(function(i, e){
return $(e).data('name');
}).get().join(',');
$('#memberNames').val(userNames);
updateMembers();
} else {
$('#error-memberName').text('User does not exist.');
}
@@ -105,12 +104,9 @@ $(function(){
$(document).on('click', '.remove', function(){
// remove member
$(this).parent().remove();
// update hidden value
var userNames = $('#members li').map(function(i, e){
return $(e).data('name');
}).get().join(',');
$('#memberNames').val(userNames);
updateMembers();
$('#members').val(members);
});
// Don't submit form by ENTER key
@@ -118,5 +114,17 @@ $(function(){
console.log(e.keyCode);
return !(e.keyCode == 13);
});
$('.is_manager').change(function(){
updateMembers();
});
function updateMembers(){
var members = $('#member-list li').map(function(i, e){
var userName = $(e).data('name');
return userName + ':' + $('#is_manager_' + userName).prop('checked');
}).get().join(',');
$('#members').val(members);
}
});
</script>

View File

@@ -0,0 +1,139 @@
@(account: Option[model.Account], members: List[(String, Boolean)])(implicit context: app.Context)
@import context._
@import view.helpers._
@main(if(account.isEmpty) "Create group" else "Edit group"){
<div>
<form id="form" method="post" action="@if(account.isEmpty){@path/groups/new} else {@path/@account.get.userName/_editgroup}" validate="true">
<div class="row-fluid">
<div class="span5">
<fieldset>
<label for="groupName" class="strong">Group name</label>
<div>
<span id="error-groupName" class="error"></span>
</div>
<input type="text" name="groupName" id="groupName" value="@account.map(_.userName)"@if(account.isDefined){ readonly}/>
</fieldset>
<fieldset>
<label class="strong">URL (Optional)</label>
<div>
<span id="error-url" class="error"></span>
</div>
<input type="text" name="url" id="url" style="width: 300px;" value="@account.map(_.url)"/>
</fieldset>
<fieldset>
<label for="avatar" class="strong">Image (Optional)</label>
@helper.html.uploadavatar(account)
</fieldset>
</div>
<div class="span7">
<fieldset>
<label class="strong">Members</label>
<ul id="member-list" class="collaborator">
</ul>
@helper.html.account("memberName", 200)
<input type="button" class="btn" value="Add" id="addMember"/>
<input type="hidden" id="members" name="members" value="@members.map(x => x._1 + ":" + x._2).mkString(",")"/>
<div>
<span class="error" id="error-members"></span>
</div>
</fieldset>
</div>
</div>
<fieldset class="margin">
@if(account.isDefined){
<div class="pull-right">
<a href="@url(account.get.userName)/_deletegroup" id="delete" class="btn btn-danger">Delete Group</a>
</div>
}
<input type="submit" class="btn btn-success" value="@if(account.isEmpty){Create Group} else {Update Group}"/>
@if(account.isDefined){
<a href="@url(account.get.userName)" class="btn">Cancel</a>
}
</fieldset>
</form>
</div>
}
<script>
$(function(){
$('input[type=submit]').click(function(){
updateMembers();
});
$('#addMember').click(function(){
$('#error-memberName').text('');
var userName = $('#memberName').val();
// check empty
if($.trim(userName) == ''){
return false;
}
// check duplication
var exists = $('#member-list li').filter(function(){
return $(this).data('name') == userName;
}).length > 0;
if(exists){
$('#error-memberName').text('User has been already added.');
return false;
}
// check existence
$.post('@path/admin/users/_usercheck', {
'userName': userName
}, function(data, status){
if(data == 'true'){
addMemberHTML(userName, false);
} else {
$('#error-memberName').text('User does not exist.');
}
});
});
$(document).on('click', '.remove', function(){
$(this).parent().remove();
});
// Don't submit form by ENTER key
$('#memberName').keypress(function(e){
return !(e.keyCode == 13);
});
$('#delete').click(function(){
return confirm('Once you delete this group, there is no going back.\nAre you sure?');
});
@members.map { case (userName, isManager) =>
addMemberHTML('@userName', @isManager);
}
function addMemberHTML(userName, isManager){
var memberButton = $('<button type="button" class="btn btn-default btn-mini" value="false">Member</button>').addClass('is_manager_' + userName);
if(!isManager){
memberButton.addClass('active');
}
var managerButton = $('<button type="button" class="btn btn-default btn-mini" value="true">Manager</button>').addClass('is_manager_' + userName);
if(isManager){
memberButton.addClass('active');
}
$('#member-list').append($('<li>')
.data('name', userName)
.append($('<div class="btn-group is_manager" data-toggle="buttons-radio">')
.append(memberButton)
.append(managerButton))
.append(' ')
.append($('<a>').attr('href', '@path/' + userName).text(userName))
.append(' ')
.append($('<a href="#" class="remove pull-right">(remove)</a>')));
}
function updateMembers(){
var members = $('#member-list li').map(function(i, e){
var userName = $(e).data('name');
return userName + ':' + $('.is_manager_' + userName + '.active').attr('value');
}).get().join(',');
$('#members').val(members);
return true;
}
});
</script>

View File

@@ -54,7 +54,11 @@
}
@if(loginAccount.isDefined){
<a href="@url(loginAccount.get.userName)" class="username menu">@avatar(loginAccount.get.userName, 20) @loginAccount.get.userName</a>
<a href="@path/new" class="menu" data-toggle="tooltip" data-placement="bottom" title="Create a new repo"><i class="icon-plus"></i></a>
<a class="dropdown-toggle menu" data-toggle="dropdown" href="#"><i class="icon-plus"></i></a>
<ul class="dropdown-menu">
<li><a href="@path/new">New repository</a></li>
<li><a href="@path/groups/new">New group</a></li>
</ul>
<a href="@url(loginAccount.get.userName)/_edit" class="menu" data-toggle="tooltip" data-placement="bottom" title="Account settings"><i class="icon-user"></i></a>
@if(loginAccount.get.isAdmin){
<a href="@path/admin/users" class="menu" data-toggle="tooltip" data-placement="bottom" title="Administration"><i class="icon-wrench"></i></a>