Keesun 2014-02-07
Added ProjectVisitationt
@caf4d2e91744dfc0e8d3948db24fe3952f252bf7
app/controllers/BoardApp.java
--- app/controllers/BoardApp.java
+++ app/controllers/BoardApp.java
@@ -176,6 +176,7 @@
             return ok(json);
         }
 
+        UserApp.currentUser().visits(project);
         Form<PostingComment> commentForm = new Form<>(PostingComment.class);
         return ok(view.render(post, commentForm, project));
     }
app/controllers/GitApp.java
--- app/controllers/GitApp.java
+++ app/controllers/GitApp.java
@@ -103,6 +103,7 @@
             return ok(RepositoryService
                     .gitAdvertise(project, service, response()));
         } else {
+            UserApp.currentUser().visits(project);
             return ok(RepositoryService
                     .gitRpc(project, service, request(), response()));
         }
app/controllers/IssueApp.java
--- app/controllers/IssueApp.java
+++ app/controllers/IssueApp.java
@@ -307,7 +307,7 @@
 
         Form<Comment> commentForm = new Form<>(Comment.class);
         Form<Issue> editForm = new Form<>(Issue.class).fill(Issue.findByNumber(project, number));
-
+        UserApp.currentUser().visits(project);
         // Determine response type with Accept header
         if (HttpUtil.isJSONPreferred(request())){
             ObjectNode result = Json.newObject();
app/controllers/MilestoneApp.java
--- app/controllers/MilestoneApp.java
+++ app/controllers/MilestoneApp.java
@@ -293,7 +293,7 @@
 
         String paramState = request().getQueryString("state");
         State state = State.getValue(paramState);
-
+        UserApp.currentUser().visits(project);
         return ok(view.render(milestone.title, milestone, project, state));
     }
 }
app/controllers/ProjectApp.java
--- app/controllers/ProjectApp.java
+++ app/controllers/ProjectApp.java
@@ -140,7 +140,7 @@
         List<PullRequest> pullRequests = PullRequest.findRecentlyReceived(project, RECENT_PULL_REQUEST_SHOW_LIMIT);
 
         List<History> histories = History.makeHistory(loginId, project, commits, issues, postings, pullRequests);
-
+        UserApp.currentUser().visits(project);
         return ok(overview.render("title.projectHome", project, histories));
     }
 
app/controllers/PullRequestApp.java
--- app/controllers/PullRequestApp.java
+++ app/controllers/PullRequestApp.java
@@ -412,7 +412,7 @@
                 comments.add(comment);
             }
         }
-
+        UserApp.currentUser().visits(project);
         return ok(view.render(project, pullRequest, comments, canDeleteBranch, canRestoreBranch));
     }
 
app/controllers/SvnApp.java
--- app/controllers/SvnApp.java
+++ app/controllers/SvnApp.java
@@ -78,6 +78,7 @@
         int status = response.waitAndGetStatus();
 
         // Send the response.
+        UserApp.currentUser().visits(project);
         return sendResponse(status, response.getInputStream());
     }
 
app/models/NullUser.java
--- app/models/NullUser.java
+++ app/models/NullUser.java
@@ -46,4 +46,9 @@
     public boolean isSiteManager() {
         return false;
     }
+
+    @Override
+    public void visits(Project project) {
+        // do nothing
+    }
 }
 
app/models/ProjectVisitation.java (added)
+++ app/models/ProjectVisitation.java
@@ -0,0 +1,69 @@
+/**
+ * Yobi, Project Hosting SW
+ *
+ * Copyright 2013 NAVER Corp.
+ * http://yobi.io
+ *
+ * @Author Keesun Baik
+ *
+ * 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 models;
+
+import play.db.ebean.Model;
+
+import javax.persistence.*;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 프로젝트 방문 기록.
+ *
+ * @author Keeun Baik
+ */
+@Entity
+public class ProjectVisitation extends Model {
+
+    private static final long serialVersionUID = 1L;
+
+    public static final Finder <Long, ProjectVisitation> find = new Finder<>(Long.class, ProjectVisitation.class);
+
+    @Id
+    public Long id;
+
+    @ManyToOne
+    public Project project;
+
+    @ManyToOne
+    @JoinColumn(name = "recently_visited_projects_id")
+    public RecentlyVisitedProjects recentlyVisitedProjects;
+
+    @Temporal(TemporalType.TIMESTAMP)
+    public Date visited;
+
+
+    public static ProjectVisitation findBy(RecentlyVisitedProjects rvp, Project project) {
+        return find.where()
+                .eq("recentlyVisitedProjects", rvp)
+                .eq("project", project)
+                .findUnique();
+    }
+
+    public static List<ProjectVisitation> findRecentlyVisitedProjects(RecentlyVisitedProjects rvp, int size) {
+        return find.where()
+                .eq("recentlyVisitedProjects", rvp)
+                .orderBy("visited desc")
+                .setMaxRows(size)
+                .findList();
+    }
+}
 
app/models/RecentlyVisitedProjects.java (added)
+++ app/models/RecentlyVisitedProjects.java
@@ -0,0 +1,98 @@
+/**
+ * Yobi, Project Hosting SW
+ *
+ * Copyright 2013 NAVER Corp.
+ * http://yobi.io
+ *
+ * @Author Keesun Baik
+ *
+ * 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 models;
+
+import play.db.ebean.Model;
+import play.db.ebean.Transactional;
+
+import javax.persistence.*;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 유저당 최근 방문 프로젝트 정보를 담고 있다.
+ *
+ * @author Keeun Baik
+ */
+@Entity
+public class RecentlyVisitedProjects extends Model {
+
+    private static final long serialVersionUID = 1L;
+
+    public static final Finder <Long, RecentlyVisitedProjects> find = new Finder<>(Long.class, RecentlyVisitedProjects.class);
+
+    @Id
+    public Long id;
+
+    @OneToOne
+    @JoinColumn(name = "user_id")
+    public User user;
+
+    @OneToMany(cascade = CascadeType.ALL)
+    @JoinColumn(name = "recently_visited_projects_id")
+    public List<ProjectVisitation> visitedProjects;
+
+    /**
+     * {@code user}가 {@code project}를 방문한 기록을 추가한다.
+     *
+     * @param user
+     * @param project
+     * @return
+     */
+    @Transactional
+    public static RecentlyVisitedProjects addNewVisitation(User user, Project project) {
+        RecentlyVisitedProjects existingOne = find.where().eq("user", user).findUnique();
+        if(existingOne != null) {
+            existingOne.add(project);
+            existingOne.update();
+            return existingOne;
+        }
+
+        RecentlyVisitedProjects newOne = new RecentlyVisitedProjects();
+        newOne.user = user;
+        newOne.add(project);
+        newOne.save();
+        return newOne;
+    }
+
+    private void add(Project project) {
+        ProjectVisitation existingPV = ProjectVisitation.findBy(this, project);
+        if(existingPV != null) {
+            existingPV.visited = new Date();
+            existingPV.update();
+        } else {
+            ProjectVisitation newPV = new ProjectVisitation();
+            newPV.recentlyVisitedProjects = this;
+            newPV.project = project;
+            newPV.visited = new Date();
+            this.visitedProjects.add(newPV);
+        }
+    }
+
+    /**
+     * 최근 방문한 프로젝트 목록을 {@code size} 개수만큼 가져온다.
+     *
+     * @return
+     */
+    public List<ProjectVisitation> findRecentlyVisitedProjects(int size) {
+        return ProjectVisitation.findRecentlyVisitedProjects(this, size);
+    }
+}
app/models/User.java
--- app/models/User.java
+++ app/models/User.java
@@ -143,6 +143,9 @@
     @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
     public List<Email> emails;
 
+    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL)
+    public RecentlyVisitedProjects recentlyVisitedProjects;
+
     public User() {
     }
 
@@ -557,4 +560,18 @@
 
         return anonymous;
     }
+
+    public void visits(Project project) {
+        this.recentlyVisitedProjects = RecentlyVisitedProjects.addNewVisitation(this, project);
+        this.update();
+    }
+
+
+    public List<ProjectVisitation> getVisitedProjects(int size) {
+        if(size < 1 || this.recentlyVisitedProjects == null) {
+            return new ArrayList<>();
+        }
+
+        return this.recentlyVisitedProjects.findRecentlyVisitedProjects(size);
+    }
 }
app/views/common/usermenu.scala.html
--- app/views/common/usermenu.scala.html
+++ app/views/common/usermenu.scala.html
@@ -46,45 +46,25 @@
                     @Messages("title.logout")
                 </a>
             </li>
-            @defining(UserApp.currentUser.getWatchingProjects()){ watchingProjects =>
+            @defining(UserApp.currentUser.getVisitedProjects(5)){ visitedProjects =>
                 <li class="title">
-                    @Messages("project.watchingproject")
-                    <span class="numberic">@watchingProjects.size</span>
+                    @Messages("project.recently.visited")
+                    <span class="numberic">@visitedProjects.size</span>
                 </li>
-                @if(watchingProjects.length > 0) {
-                    @for(project <- watchingProjects){
-                        <li><a href="@routes.ProjectApp.project(project.owner, project.name)"><span class="gray">@project.owner / </span><span class="bold">@project.name</span></a></li>
+                @if(visitedProjects.length > 0) {
+                    @for(pv <- visitedProjects){
+                        <li><a href="@routes.ProjectApp.project(pv.project.owner, pv.project.name)"><span class="gray">@pv.project.owner / </span><span class="bold">@pv.project.name</span></a></li>
                     }
                 } else {
-                    <li class="empty">@Messages("project.is.empty")</li>
-                }
-            }
-
-            @defining(Project.findProjectsCreatedByUser(UserApp.currentUser.loginId, orderString)) { myProjects =>
-                <li class="title">
-                    @Messages("project.createdByMe")
-                    <span class="numberic">@myProjects.size</span>
-                </li>
-                @if(myProjects.length > 0) {
-                    @for(project <- myProjects){
-                        <li><a href="@routes.ProjectApp.project(project.owner, project.name)"><span class="gray">@project.owner / </span><span class="bold">@project.name</span></a></li>
+                    @defining(Project.findProjectsCreatedByUser(UserApp.currentUser.loginId, orderString)) { myProjects =>
+                        @if(myProjects.length > 0) {
+                            @for(project <- myProjects){
+                                <li><a href="@routes.ProjectApp.project(project.owner, project.name)"><span class="gray">@project.owner / </span><span class="bold">@project.name</span></a></li>
+                            }
+                        } else {
+                            <li class="empty">@Messages("project.is.empty")</li>
+                        }
                     }
-                } else {
-                    <li class="empty">@Messages("project.is.empty")</li>
-                }
-            }
-
-            @defining(Project.findProjectsJustMemberAndNotOwner(UserApp.currentUser, orderString)) { memberProjects =>
-                <li class="title">
-                    @Messages("project.default.group.member")
-                    <span class="numberic">@memberProjects.size</span>
-                </li>
-                @if(memberProjects.length > 0) {
-                    @for(project <- memberProjects){
-                        <li><a href="@routes.ProjectApp.project(project.owner, project.name)"><span class="gray">@project.owner / </span><span class="bold">@project.name</span></a></li>
-                    }
-                } else {
-                    <li class="empty">@Messages("project.is.empty")</li>
                 }
             }
         </ul>
app/views/index/myProjectList.scala.html
--- app/views/index/myProjectList.scala.html
+++ app/views/index/myProjectList.scala.html
@@ -23,7 +23,27 @@
         </ul>
     </div>
 }
-
+@defining(currentUser.getVisitedProjects(10)){ visitedProjects =>
+    <div class="my-projects">
+        <div class="title">@Messages("project.recently.visited") (@visitedProjects.size())</div>
+        <ul class="unstyled">
+        @for(pv <- visitedProjects){
+            <li class="my-project">
+                <div class="my-project-header">
+                    <div class="name-wrap">
+                        @if(pv.project.owner != currentUser.loginId) {
+                        <a href="@routes.UserApp.userInfo(pv.project.owner)">@pv.project.owner</a> /
+                        }
+                        <a href="@routes.ProjectApp.project(pv.project.owner, pv.project.name)" class="project-name">
+                            <strong>@pv.project.name @if(!pv.project.isPublic){ <i class="yobicon-lock yobicon-small"></i> }</strong>
+                        </a>
+                    </div>
+                </div>
+            </li>
+        }
+        </ul>
+    </div>
+}
 @orderString = @{"createdDate DESC"}
 @displayProjects(Messages("project.default.group.watching"), currentUser.getWatchingProjects(orderString))
 @displayProjects(Messages("project.createdByMe"), Project.findProjectsCreatedByUser(currentUser.loginId, orderString))
 
conf/evolutions/default/62.sql (added)
+++ conf/evolutions/default/62.sql
@@ -0,0 +1,30 @@
+# --- !Ups
+create table recently_visited_projects (
+  id                        bigint not null,
+  user_id                   bigint not null,
+  constraint pk_recently_visited_projects primary key (id)
+);
+create sequence recently_visited_projects_seq;
+create index ix_recently_visited_projects_user on recently_visited_projects (user_id);
+alter table recently_visited_projects add constraint fk_user_id foreign key (user_id) references n4user (id) on delete restrict on update restrict;
+
+create table project_visitation (
+  id                            bigint not null,
+  visited                       timestamp,
+  project_id                    bigint not null,
+  recently_visited_projects_id  bigint not null,
+  constraint pk_project_visitation primary key (id)
+);
+create sequence project_visitation_seq;
+alter table project_visitation add constraint fk_project_visit_project_id foreign key (project_id) references project (id) on delete restrict on update restrict;
+alter table project_visitation add constraint fk_recently_visited_projects_id foreign key (recently_visited_projects_id) references recently_visited_projects (id) on delete restrict on update restrict;
+
+# --- !Downs
+alter table project_visitation drop constraint if exists fk_project_visit_project_id;
+alter table project_visitation drop constraint if exists fk_recently_visited_projects_id;
+drop sequence if exists project_visitation_seq;
+drop table if exists project_visitation;
+
+alter table recently_visited_projects drop constraint if exists fk_user_id;
+drop sequence if exists recently_visited_projects_seq;
+drop table if exists recently_visited_projects;
conf/messages
--- conf/messages
+++ conf/messages
@@ -412,6 +412,7 @@
 project.private.notice = Basic information (name, description, etc.) is exposed to all users, even thought it is a private project.
 project.projects = projects
 project.public = PUBLIC
+project.recently.visited = Recently visited
 project.readme = You can see README.md here if you add it into the code repository's root directory.
 project.reviewer.count = Reviewers
 project.reviewer.count.description = reviewers is necessary to accept pull-request.
conf/messages.ko
--- conf/messages.ko
+++ conf/messages.ko
@@ -414,6 +414,7 @@
 project.projects = 프로젝트
 project.public = 공개
 project.readme = 프로젝트에 대한 설명을 README.md 파일로 작성해서 코드저장소 루트 디렉토리에 추가하면 이 곳에 나타납니다.
+project.recently.visited = 최근 방문한 프로젝트
 project.reviewer.count.description = 명이 리뷰를 완료하면 코드를 받을 수 있습니다.
 project.reviewer.count.disable = 사용 안함
 project.reviewer.count.enable = 사용
 
test/models/RecentlyVisitedProjectsTest.java (added)
+++ test/models/RecentlyVisitedProjectsTest.java
@@ -0,0 +1,138 @@
+/**
+ * Yobi, Project Hosting SW
+ *
+ * Copyright 2013 NAVER Corp.
+ * http://yobi.io
+ *
+ * @Author Keesun Baik
+ *
+ * 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 models;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.List;
+
+import static org.fest.assertions.Assertions.assertThat;
+
+/**
+ * @author Keeun Baik
+ */
+public class RecentlyVisitedProjectsTest extends ModelTest<RecentlyVisitedProjects> {
+
+    User doortts;
+    User nori;
+    Project yobi;
+    Project cubrid;
+
+    @Before
+    public void setup() {
+        doortts = User.findByLoginId("doortts");
+        nori = User.findByLoginId("nori");
+        yobi = Project.findByOwnerAndProjectName("yobi", "projectYobi");
+        cubrid = Project.findByOwnerAndProjectName("doortts", "CUBRID");
+    }
+
+    @Test
+    public void addVisit() {
+        // When
+        doortts.visits(yobi);
+
+        // Then
+        assertThat(RecentlyVisitedProjects.find.all().size()).isEqualTo(1);
+        assertThat(doortts.recentlyVisitedProjects.visitedProjects.size()).isEqualTo(1);
+    }
+
+    @Test
+    public void addVisitsWithTheSameProject() {
+        // Given
+        doortts.visits(yobi);
+        assertThat(RecentlyVisitedProjects.find.all().size()).isEqualTo(1);
+        assertThat(doortts.recentlyVisitedProjects.visitedProjects.size()).isEqualTo(1);
+
+        // When
+        doortts.visits(yobi);
+
+        // Then
+        assertThat(RecentlyVisitedProjects.find.all().size()).isEqualTo(1);
+        assertThat(doortts.recentlyVisitedProjects.visitedProjects.size()).isEqualTo(1);
+    }
+
+    @Test
+    public void addVisitsWithTheDiffrentProjects() {
+        // Given
+        doortts.visits(yobi);
+        assertThat(RecentlyVisitedProjects.find.all().size()).isEqualTo(1);
+        assertThat(doortts.recentlyVisitedProjects.visitedProjects.size()).isEqualTo(1);
+
+        // When
+        doortts.visits(cubrid);
+
+        // Then
+        assertThat(RecentlyVisitedProjects.find.all().size()).isEqualTo(1);
+        assertThat(doortts.recentlyVisitedProjects.visitedProjects.size()).isEqualTo(2);
+    }
+
+    @Test
+    public void addVisitsWithDifferentUsers() {
+        // Given
+        doortts.visits(yobi);
+        assertThat(RecentlyVisitedProjects.find.all().size()).isEqualTo(1);
+        assertThat(doortts.recentlyVisitedProjects.visitedProjects.size()).isEqualTo(1);
+
+        // When
+        nori.visits(yobi);
+
+        // Then
+        assertThat(RecentlyVisitedProjects.find.all().size()).isEqualTo(2);
+        assertThat(doortts.recentlyVisitedProjects.visitedProjects.size()).isEqualTo(1);
+        assertThat(nori.recentlyVisitedProjects.visitedProjects.size()).isEqualTo(1);
+    }
+
+    @Test
+    public void recentlyVisitedProjects() throws InterruptedException {
+        // Given
+        doortts.visits(yobi);
+        Thread.sleep(1000l);
+        doortts.visits(cubrid);
+
+        // When
+        List<ProjectVisitation> projects = doortts.getVisitedProjects();
+
+        // Then
+        assertThat(projects.size()).isEqualTo(2);
+        assertThat(projects.get(0).project).isEqualTo(cubrid);
+        assertThat(projects.get(1).project).isEqualTo(yobi);
+    }
+
+    @Test
+    public void recentlyVisitedProjectsWithRevisitation() throws InterruptedException {
+        // Given
+        doortts.visits(yobi);
+        Thread.sleep(1000l);
+        doortts.visits(cubrid);
+        Thread.sleep(1000l);
+        doortts.visits(yobi);
+
+        // When
+        List<ProjectVisitation> projects = doortts.getVisitedProjects();
+
+        // Then
+        assertThat(projects.size()).isEqualTo(2);
+        assertThat(projects.get(0).project).isEqualTo(yobi);
+        assertThat(projects.get(1).project).isEqualTo(cubrid);
+    }
+
+}
Add a comment
List