백기선 2014-02-25
Merge branch `issue-278` of changsung/yobi
from pull request 593
@d9adbd98e1b1a7aafa5881cc0c63b6fa5d3c1780
app/controllers/ImportApp.java
--- app/controllers/ImportApp.java
+++ app/controllers/ImportApp.java
@@ -44,7 +44,7 @@
      */
     @Transactional
     public static Result newProject() throws GitAPIException, IOException {
-        if( !AccessControl.isCreatable(UserApp.currentUser()) ){
+        if( !AccessControl.isGlobalResourceCreatable(UserApp.currentUser()) ){
             return forbidden("'" + UserApp.currentUser().name + "' has no permission");
         }
 
app/controllers/IssueApp.java
--- app/controllers/IssueApp.java
+++ app/controllers/IssueApp.java
@@ -586,6 +586,36 @@
         return redirect(redirectTo);
     }
 
+    private static void addAssigneeChangedNotification(Issue modifiedIssue, Issue originalIssue, Call redirectTo) {
+        if(!originalIssue.assignedUserEquals(modifiedIssue.assignee)) {
+            Issue updatedIssue = Issue.finder.byId(originalIssue.id);
+            User oldAssignee = null;
+            if(originalIssue.assignee != null) {
+                oldAssignee = originalIssue.assignee.user;
+            }
+            NotificationEvent notiEvent = NotificationEvent.afterAssigneeChanged(oldAssignee, updatedIssue, redirectTo.absoluteURL(request()));
+            IssueEvent.addFromNotificationEvent(notiEvent, modifiedIssue, UserApp.currentUser().loginId);
+        }
+    }
+
+    private static void addStateChangedNotification(Issue modifiedIssue, Issue originalIssue, Call redirectTo) {
+        if(modifiedIssue.state != originalIssue.state) {
+            Issue updatedIssue = Issue.finder.byId(originalIssue.id);
+            NotificationEvent notiEvent = NotificationEvent.afterStateChanged(originalIssue.state, updatedIssue,
+                    redirectTo.absoluteURL(request()));
+            IssueEvent.addFromNotificationEvent(notiEvent, modifiedIssue, UserApp.currentUser().loginId);
+        }
+    }
+
+    private static void addBodyChangedNotification(Issue modifiedIssue, Issue originalIssue, Call redirectTo) {
+        if (!modifiedIssue.body.equals(originalIssue.body)) {
+            Issue updatedIssue = Issue.finder.byId(originalIssue.id);
+            NotificationEvent notiEvent = NotificationEvent.afterIssueBodyChanged(originalIssue.body, updatedIssue,
+                    redirectTo.absoluteURL(request()));
+            IssueEvent.addFromNotificationEvent(notiEvent, modifiedIssue, UserApp.currentUser().loginId);
+        }
+    }
+
     /**
      * 이슈 수정
      *
@@ -624,29 +654,20 @@
         Runnable updateIssueBeforeSave = new Runnable() {
             @Override
             public void run() {
+                // Below addAll() method is needed to avoid the exception, 'Timeout trying to lock table ISSUE'.
+                // This is just workaround and the cause of the exception is not figured out yet.
+                // Do not replace it to 'issue.comments = originalIssue.comments;'
+                issue.voters.addAll(originalIssue.voters);
                 issue.comments = originalIssue.comments;
                 addLabels(issue, request());
             }
         };
 
+        addAssigneeChangedNotification(issue, originalIssue, redirectTo);
+        addStateChangedNotification(issue, originalIssue, redirectTo);
+        addBodyChangedNotification(issue, originalIssue, redirectTo);
+
         Result result = editPosting(originalIssue, issue, issueForm, redirectTo, updateIssueBeforeSave);
-
-        if(!originalIssue.assignedUserEquals(issue.assignee)) {
-            Issue updatedIssue = Issue.finder.byId(originalIssue.id);
-            User oldAssignee = null;
-            if(originalIssue.assignee != null) {
-                oldAssignee = originalIssue.assignee.user;
-            }
-            NotificationEvent notiEvent = NotificationEvent.afterAssigneeChanged(oldAssignee, updatedIssue, redirectTo.absoluteURL(request()));
-            IssueEvent.addFromNotificationEvent(notiEvent, issue, UserApp.currentUser().loginId);
-        }
-
-        if(issue.state != originalIssue.state) {
-            Issue updatedIssue = Issue.finder.byId(originalIssue.id);
-            NotificationEvent notiEvent = NotificationEvent.afterStateChanged(originalIssue.state, updatedIssue,
-                    redirectTo.absoluteURL(request()));
-            IssueEvent.addFromNotificationEvent(notiEvent, issue, UserApp.currentUser().loginId);
-        }
 
         return result;
     }
app/controllers/ProjectApp.java
--- app/controllers/ProjectApp.java
+++ app/controllers/ProjectApp.java
@@ -191,7 +191,7 @@
      */
     @Transactional
     public static Result newProject() throws Exception {
-        if( !AccessControl.isCreatable(UserApp.currentUser()) ){
+        if( !AccessControl.isGlobalResourceCreatable(UserApp.currentUser()) ){
            return forbidden(ErrorViews.Forbidden.render("'" + UserApp.currentUser().name + "' has no permission"));
         }
         Form<Project> filledNewProjectForm = form(Project.class).bindFromRequest();
 
app/controllers/VoteApp.java (added)
+++ app/controllers/VoteApp.java
@@ -0,0 +1,86 @@
+/*
+ * Yobi, Project Hosting SW
+ *
+ * Copyright 2013 NAVER Corp.
+ * http://yobi.io
+ *
+ * @Author Changsung Kim
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package controllers;
+
+import models.Issue;
+import play.mvc.Call;
+import play.mvc.With;
+import models.Project;
+import play.mvc.Result;
+import play.mvc.Controller;
+import play.db.ebean.Transactional;
+import actions.AnonymousCheckAction;
+import models.enumeration.ResourceType;
+import controllers.annotation.IsCreatable;
+
+/**
+ * 이슈에서 투표하기 위한 Controller
+ */
+@With(AnonymousCheckAction.class)
+public class VoteApp extends Controller {
+
+    /**
+     * 투표하기
+     *
+     * 투표요청이 들어온 이슈에서 로그인 사용자의 투표가 등록된다.
+     *
+     * @param ownerName
+     * @param projectName
+     * @param issueNumber
+     * @return
+     */
+    @Transactional
+    @IsCreatable(ResourceType.ISSUE_COMMENT)
+    public static Result vote(String ownerName, String projectName, Long issueNumber) {
+
+        Project project = Project.findByOwnerAndProjectName(ownerName, projectName);
+        Issue issue = Issue.findByNumber(project, issueNumber);
+
+        issue.addVoter(UserApp.currentUser());
+
+        Call call = routes.IssueApp.issue(ownerName, projectName, issueNumber);
+
+        return redirect(call);
+    }
+
+    /**
+     * 투표 취소하기
+     *
+     * 투표요청이 들어온 이슈에서 로그인 사용자의 투표가 취소된다.
+     *
+     * @param ownerName
+     * @param projectName
+     * @param issueNumber
+     * @return
+     */
+    @Transactional
+    @IsCreatable(ResourceType.ISSUE_COMMENT)
+    public static Result unvote(String ownerName, String projectName, Long issueNumber) {
+        Project project = Project.findByOwnerAndProjectName(ownerName, projectName);
+        Issue issue = Issue.findByNumber(project, issueNumber);
+
+        issue.removeVoter(UserApp.currentUser());
+
+        Call call = routes.IssueApp.issue(ownerName, projectName, issueNumber);
+
+        return redirect(call);
+    }
+}
app/models/Issue.java
--- app/models/Issue.java
+++ app/models/Issue.java
@@ -73,6 +73,14 @@
     @OneToMany(cascade = CascadeType.ALL, mappedBy="issue")
     public List<IssueEvent> events;
 
+    @ManyToMany(cascade = CascadeType.ALL)
+    @JoinTable(
+            name = "issue_voter",
+            joinColumns = @JoinColumn(name = "issue_id"),
+            inverseJoinColumns = @JoinColumn(name = "user_id")
+    )
+    public List<User> voters = new ArrayList<>();
+
     /**
      * @return
      * @see models.AbstractPosting#computeNumOfComments()
@@ -328,9 +336,9 @@
     }
 
     /**
-     * 이 이슈를 지켜보고 있는 모든 사용자들을 얻는다.
+     * 이 이슈를 지켜보고 있는 모든 사용자와 이슈에 투표를 한 모든 사용자를 얻는다.
      *
-     * @return 이 이슈를 지켜보고 있는 모든 사용자들의 집합
+     * @return 이 이슈를 지켜보고 있는 모든 사용자와 이슈에 투표를 한 모든 사용자의 집합.
      */
     @Transient
     public Set<User> getWatchers() {
@@ -338,6 +346,8 @@
         if (assignee != null) {
             baseWatchers.add(assignee.user);
         }
+        baseWatchers.addAll(this.voters);
+
         return super.getWatchers(baseWatchers);
     }
 
@@ -490,4 +500,35 @@
 
         return true;
     }
+
+    /**
+     * {@code user}를 투표자로 추가한다.
+     *
+     * @param user
+     */
+    public void addVoter(User user) {
+        this.voters.add(user);
+        this.update();
+    }
+
+    /**
+     * {@code user}의 투표를 취소한다.
+     *
+     * @param user
+     */
+    public void removeVoter(User user) {
+        this.voters.remove(user);
+        this.update();
+    }
+
+    /**
+     * {@code user}의 투표 여부를 반환한다.
+     *
+     * @param user
+     * @return 투표를 했으면 true, 아니면 false.
+     */
+    public boolean isVotedBy(User user) {
+        return this.voters.contains(user);
+    }
+
 }
app/models/NotificationEvent.java
--- app/models/NotificationEvent.java
+++ app/models/NotificationEvent.java
@@ -108,6 +108,7 @@
             case NEW_PULL_REQUEST:
             case NEW_PULL_REQUEST_COMMENT:
             case NEW_COMMIT:
+            case ISSUE_BODY_CHANGED:
                 return newValue;
             case PULL_REQUEST_STATE_CHANGED:
                 if (State.OPEN.state().equals(newValue)) {
@@ -459,6 +460,20 @@
         NotificationEvent.add(notiEvent);
     }
 
+    public static NotificationEvent afterIssueBodyChanged(String oldBody, Issue issue, String urlToView) {
+        NotificationEvent notiEvent = createFromCurrentUser(issue);
+        notiEvent.title = formatReplyTitle(issue);
+        notiEvent.urlToView = urlToView;
+        notiEvent.receivers = getReceivers(issue);
+        notiEvent.eventType = EventType.ISSUE_BODY_CHANGED;
+        notiEvent.oldValue = oldBody;
+        notiEvent.newValue = issue.body;
+
+        NotificationEvent.add(notiEvent);
+
+        return notiEvent;
+    }
+
     public static void afterNewPost(Posting post) {
         NotificationEvent notiEvent = createFromCurrentUser(post);
         notiEvent.title = formatNewTitle(post);
app/models/enumeration/EventType.java
--- app/models/enumeration/EventType.java
+++ app/models/enumeration/EventType.java
@@ -21,6 +21,7 @@
     NEW_COMMIT("notification.type.new.commit", 13),
     PULL_REQUEST_REVIEWED("notification.type.pullrequest.reviewed", 14),
     PULL_REQUEST_UNREVIEWED("notification.type.pullrequest.unreviewed", 15),
+    ISSUE_BODY_CHANGED("notification.type.issue.body.changed", 17),
     ISSUE_REFERRED_FROM_PULL_REQUEST("notification.type.issue.referred.from.pullrequest", 16);
 
     private String descr;
app/utils/AccessControl.java
--- app/utils/AccessControl.java
+++ app/utils/AccessControl.java
@@ -21,7 +21,7 @@
      * @param user
      * @return user가 해당 resourceType을 생성할 수 있는지 여부
      */
-    public static boolean isCreatable(User user) {
+    public static boolean isGlobalResourceCreatable(User user) {
         return !user.isAnonymous();
     }
 
@@ -147,7 +147,7 @@
      * @return
      */
     private static boolean isProjectResourceAllowed(User user, Project project, Resource resource, Operation operation) {
-        if (ProjectUser.isManager(user.id, project.id)) {
+        if (user.isSiteManager() || ProjectUser.isManager(user.id, project.id)) {
             return true;
         }
 
app/views/issue/partial_comments.scala.html
--- app/views/issue/partial_comments.scala.html
+++ app/views/issue/partial_comments.scala.html
@@ -119,6 +119,8 @@
                         @Html(Messages("issue.event.referred",linkToUser(user.loginId, user.name),linkToPullRequest(pull)))
                     }
                 }
+                case EventType.ISSUE_BODY_CHANGED => {
+                }
                 case _ => {
                     @event.newValue by @linkToUser(user.loginId, user.name)
                 }
app/views/issue/view.scala.html
--- app/views/issue/view.scala.html
+++ app/views/issue/view.scala.html
@@ -195,6 +195,24 @@
                         </dl>
                         @**<!-- // -->**@
 
+                        @**<!-- voters -->**@
+                        <dl>
+                            <dt>@Messages("issue.vote")</dt>
+                            <dd>
+                                @if(isProjectResourceCreatable(User.findByLoginId(session.get("loginId")), project, ResourceType.ISSUE_COMMENT)) {
+                                    @if(issue.isVotedBy(UserApp.currentUser())) {
+                                        <a href="@routes.VoteApp.unvote(project.owner, project.name, issue.getNumber)" data-request-method="post" class="ybtn ybtn-success"  data-toggle="tooltip" data-placement="right" data-original-title="@Messages("issue.unvote.description")">+@issue.voters.size</a>
+                                    } else {
+                                        <a href="@routes.VoteApp.vote(project.owner, project.name, issue.getNumber)" data-request-method="post" class="ybtn" data-toggle="tooltip" data-placement="right" data-original-title="@Messages("issue.vote.description")">+@issue.voters.size</a>
+                                    }
+                                    <br><br>
+                                }
+                                @for(voter <- issue.voters) {
+                                    @Html(getUserAvatar(voter, "medium"))
+                                }
+                            </dd>
+                        </dl>
+                        @**<!-- // -->**@
                     </form>
                 </div>
             </div>
 
conf/evolutions/default/63.sql (added)
+++ conf/evolutions/default/63.sql
@@ -0,0 +1,18 @@
+# --- !Ups
+create table issue_voter (
+    issue_id BIGINT NOT NULL,
+    user_id INT NOT NULL
+);
+
+alter table issue_voter add constraint fk_issue_voter_1 foreign key (issue_id) references issue (id) on delete restrict on update restrict;
+alter table issue_voter add constraint fk_issue_voter_2 foreign key (user_id) references n4user (id) on delete restrict on update restrict;
+
+ALTER TABLE issue_event DROP CONSTRAINT IF EXISTS ck_issue_event_event_type;
+ALTER TABLE issue_event ADD CONSTRAINT ck_issue_event_event_type check (event_type in ('NEW_ISSUE','NEW_POSTING','ISSUE_ASSIGNEE_CHANGED','ISSUE_STATE_CHANGED','NEW_COMMENT','NEW_COMMIT_COMMENT','NEW_PULL_REQUEST','NEW_PULL_REQUEST_COMMENT','PULL_REQUEST_STATE_CHANGED','ISSUE_REFERRED_FROM_COMMIT','ISSUE_REFERRED_FROM_PULL_REQUEST', 'ISSUE_BODY_CHANGED'));
+# --- !Downs
+alter table issue_voter drop constraint if exists fk_issue_voter_1;
+alter table issue_voter drop constraint if exists fk_issue_voter_2;
+drop table if exists issue_voter;
+
+ALTER TABLE issue_event DROP CONSTRAINT IF EXISTS ck_issue_event_event_type;
+ALTER TABLE issue_event ADD CONSTRAINT ck_issue_event_event_type check (event_type in ('NEW_ISSUE','NEW_POSTING','ISSUE_ASSIGNEE_CHANGED','ISSUE_STATE_CHANGED','NEW_COMMENT','NEW_COMMIT_COMMENT','NEW_PULL_REQUEST','NEW_PULL_REQUEST_COMMENT','PULL_REQUEST_STATE_CHANGED','ISSUE_REFERRED_FROM_COMMIT','ISSUE_REFERRED_FROM_PULL_REQUEST'));
conf/messages
--- conf/messages
+++ conf/messages
@@ -239,6 +239,9 @@
 issue.update.detachLabel = Detach label
 issue.update.milestone = Update milestone
 issue.update.state = Update state
+issue.vote = Sympathy
+issue.vote.description = Click if you sympathize with this.
+issue.unvote.description = Click if you don't sympathize with this.
 issue.watch.start = You will receive notifications of this issue
 label = Label
 label.add = Add Label
@@ -304,6 +307,7 @@
 notification.type.issue.referred.from.commit = Issue Referred from Commit
 notification.type.issue.referred.from.pullrequest = Issue Referred from PullRequest
 notification.type.issue.state.changed = Changed Issue's State
+notification.type.issue.body.changed = Changed Issue's Body
 notification.type.member.enroll = Requests for joining projects
 notification.type.new.comment = New Comment on a Posting or a Issue
 notification.type.new.commit = New Commits on a Project
conf/messages.ko
--- conf/messages.ko
+++ conf/messages.ko
@@ -239,6 +239,9 @@
 issue.update.detachLabel = 라벨 제거
 issue.update.milestone = 마일스톤 변경
 issue.update.state = 상태 변경
+issue.vote = 공감
+issue.vote.description = 이 이슈에 공감하기 위해 버튼을 누릅니다.
+issue.unvote.description = 공감을 취소하려면 버튼을 누릅니다.
 issue.watch.start = 이제 이 이슈에 관한 알림을 받습니다
 label = 라벨
 label.add = 라벨 추가
@@ -304,6 +307,7 @@
 notification.type.issue.referred.from.commit = 커밋에서의 이슈 언급
 notification.type.issue.referred.from.pullrequest = 코드 주고받기에서의 이슈 언급
 notification.type.issue.state.changed = 이슈 상태 변경
+notification.type.issue.body.changed = 이슈 본문 변경
 notification.type.member.enroll = 멤버 등록 요청
 notification.type.new.comment = 게시물과 이슈 새 댓글 등록
 notification.type.new.commit = 새 커밋
conf/routes
--- conf/routes
+++ conf/routes
@@ -243,5 +243,9 @@
 # Compare
 GET            /:user/:project/compare/:revA..:revB                                             controllers.CompareApp.compare(user, project, revA, revB)
 
+# Vote
+POST           /:user/:project/issue/:number/vote                                     controllers.VoteApp.vote(user, project, number: Long)
+POST           /:user/:project/issue/:number/unvote                                   controllers.VoteApp.unvote(user, project, number: Long)
+
 # remove trailing slash - must be the bottom of this file
 GET            /*paths                                                                controllers.Application.removeTrailer(paths)
test/models/IssueTest.java
--- test/models/IssueTest.java
+++ test/models/IssueTest.java
@@ -50,6 +50,39 @@
     }
 
     @Test
+    public void vote() {
+        // when
+        issue.addVoter(admin);
+        issue.addVoter(manager);
+
+        // then
+        assertThat(issue.voters.size()).isEqualTo(2);
+    }
+
+    @Test
+    public void unvote() {
+        // given
+        issue.addVoter(admin);
+        issue.addVoter(manager);
+
+        // when
+        issue.removeVoter(admin);
+
+        // then
+        assertThat(issue.voters.size()).isEqualTo(1);
+    }
+
+    @Test
+    public void watchersAfterVoting() {
+        // when
+        issue.addVoter(member);
+        issue.addVoter(manager);
+
+        // then
+        assertThat(issue.getWatchers().size()).isEqualTo(3);
+    }
+
+    @Test
     public void unwatchByDefault() {
         // given
         assertThat(issue.getWatchers().contains(admin)).isFalse();
Add a comment
List