Suwon Chae 2015-04-13
project-transfer: preserve previous links after project was renamed or moved
When a project is renamed or moved to another place,
previous links are broken. So, it is required
to preserve previous links not to cause confusion.

Notes:
  Once you rename or move a project, only links related to the first place of project
  are preserved for 24 hours. Which means that no matter how many times
  you change project's name or transfer it, just only first location
  before the change started will be preserved for 24 hours
@fd191e6185bac7e4cf3b6d244d153b7d95592996
app/actions/AbstractProjectCheckAction.java
--- app/actions/AbstractProjectCheckAction.java
+++ app/actions/AbstractProjectCheckAction.java
@@ -29,11 +29,11 @@
 import play.mvc.Action;
 import play.mvc.Http.Context;
 import play.mvc.Result;
-import play.mvc.Result;
 import play.libs.F.Promise;
 import utils.AccessControl;
 import utils.AccessLogger;
 import utils.ErrorViews;
+import utils.RedirectUtil;
 
 import static play.mvc.Controller.flash;
 
@@ -58,6 +58,11 @@
         Promise<Result> promise;
 
         if (project == null) {
+            Project previousProject = Project.findByPreviousPlaceOf(ownerLoginId, projectName);
+            if (previousProject != null) {
+                return RedirectUtil.redirect(previousProject);
+            }
+
             if (UserApp.currentUser() == User.anonymous){
                 flash("failed", Messages.get("error.auth.unauthorized.waringMessage"));
                 promise = Promise.pure((Result) forbidden(ErrorViews.Forbidden.render("error.forbidden.or.notfound", context.request().path())));
app/actions/NullProjectCheckAction.java
--- app/actions/NullProjectCheckAction.java
+++ app/actions/NullProjectCheckAction.java
@@ -28,10 +28,10 @@
 import play.mvc.Action;
 import play.mvc.Http;
 import play.mvc.Result;
-import play.mvc.Result;
 import play.libs.F.Promise;
 import utils.AccessLogger;
 import utils.ErrorViews;
+import utils.RedirectUtil;
 
 import static play.mvc.Controller.flash;
 
@@ -54,6 +54,10 @@
 
         if (project == null) {
             Promise<Result> promise;
+            Project previousProject = Project.findByPreviousPlaceOf(ownerLoginId, projectName);
+            if (previousProject != null) {
+                return RedirectUtil.redirect(previousProject);
+            }
 
             if (UserApp.currentUser() == User.anonymous){
                 flash("failed", Messages.get("error.auth.unauthorized.waringMessage"));
app/actions/support/PathParser.java
--- app/actions/support/PathParser.java
+++ app/actions/support/PathParser.java
@@ -24,6 +24,7 @@
 import play.mvc.Http;
 
 import javax.annotation.Nonnull;
+import java.util.Arrays;
 
 /**
  * Parse URLs related with Project.
@@ -71,4 +72,11 @@
         return DELIM + StringUtils.join(this.pathSegments, DELIM);
     }
 
+    public String restOfPathExceptOwnerAndProjectName() {
+        if(pathSegments != null && pathSegments.length > 2){
+            return StringUtils.join(Arrays.copyOfRange(pathSegments, 2, pathSegments.length), "/");
+        } else {
+            return "";
+        }
+    }
 }
app/controllers/GitApp.java
--- app/controllers/GitApp.java
+++ app/controllers/GitApp.java
@@ -89,7 +89,12 @@
         Project project = Project.findByOwnerAndProjectName(ownerName, projectName);
 
         if (project == null) {
-            return notFound();
+            Project previousProject = Project.findByPreviousPlaceOf(ownerName, projectName);
+            if (previousProject != null) {
+                project = previousProject;
+            } else {
+                return notFound();
+            }
         }
 
         if (!project.vcs.equals(RepositoryService.VCS_GIT)) {
@@ -146,5 +151,4 @@
             throws UnsupportedOperationException, IOException, ServletException {
         return GitApp.service(ownerName, projectName, service, false);
     }
-
 }
app/controllers/ProjectApp.java
--- app/controllers/ProjectApp.java
+++ app/controllers/ProjectApp.java
@@ -263,6 +263,7 @@
         }
 
         if (!project.name.equals(updatedProject.name)) {
+            updatedProject.recordRenameOrTransferHistoryIfLastChangePassed24HoursFrom(project);
             if (!repository.renameTo(updatedProject.name)) {
                 throw new FileOperationException("fail repository rename to " + project.owner + "/" + updatedProject.name);
             }
@@ -592,6 +593,7 @@
         Organization newOwnerOrg = Organization.findByName(pt.destination);
 
         // Change the project's information.
+        project.recordRenameOrTransferHistoryIfLastChangePassed24HoursFrom(project);
         project.owner = pt.destination;
         project.name = newProjectName;
         if (newOwnerOrg != null) {
app/controllers/SvnApp.java
--- app/controllers/SvnApp.java
+++ app/controllers/SvnApp.java
@@ -108,7 +108,12 @@
         Project project = Project.findByOwnerAndProjectName(userName, projectName);
 
         if (project == null) {
-            return notFound();
+            Project previousProject = Project.findByPreviousPlaceOf(userName, projectName);
+            if (previousProject != null) {
+                project = previousProject;
+            } else {
+                return notFound();
+            }
         }
 
         if (!project.vcs.equals(RepositoryService.VCS_SUBVERSION)) {
app/models/Project.java
--- app/models/Project.java
+++ app/models/Project.java
@@ -29,6 +29,7 @@
 import models.enumeration.RoleType;
 import models.resource.GlobalResource;
 import models.resource.Resource;
+import org.apache.commons.collections.CollectionUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.eclipse.jgit.api.errors.NoHeadException;
@@ -43,6 +44,7 @@
 import utils.JodaDateUtil;
 import validation.ExConstraints;
 
+import javax.annotation.Nonnull;
 import javax.persistence.*;
 import javax.servlet.ServletException;
 import java.io.IOException;
@@ -131,6 +133,10 @@
 
     @OneToOne(mappedBy = "project", cascade = CascadeType.ALL)
     public ProjectMenuSetting menuSetting;
+
+    private String previousOwnerLoginId;
+    private String previousName;
+    private Long previousNameChangedTime;
 
     /**
      * @see {@link User#SITE_MANAGER_ID}
@@ -578,6 +584,27 @@
         return menuSetting == null || menuSetting.code;
     }
 
+    /**
+     * When a project is renamed or transferred, record it's previous location and change time
+     *
+     * @param project
+     */
+    public void recordRenameOrTransferHistoryIfLastChangePassed24HoursFrom(@Nonnull Project project) {
+        if(isRenamedOrTransferredIn24Hours(project)) {
+            this.previousNameChangedTime = DateTime.now().getMillis();
+            this.previousName = project.name;
+            this.previousOwnerLoginId = project.owner;
+        }
+    }
+
+    private static boolean isRenamedOrTransferredIn24Hours(@Nonnull Project project) {
+        return project.previousNameChangedTime == null || hasPassed24hoursFrom(project.previousNameChangedTime);
+    }
+
+    private static boolean hasPassed24hoursFrom(Long time) {
+        return new Duration(DateTime.now().getMillis() - time).getStandardHours() > 24;
+    }
+
     public enum State {
         PUBLIC, PRIVATE, ALL
     }
@@ -780,4 +807,49 @@
             return RepositoryService.VCS_GIT;
         }
     }
+
+    /**
+     * Find project with previous owner and previous project name
+     *
+     * when to use:
+     *  When specific project can't be found.
+     *
+     *  In some cases, it is reasonable to assume that project was moved or transferred.
+     *  In that case, try this method.
+     *
+     *  This method is intended to be used at controllers.
+     *
+     * @param previousOwnerLoginid
+     * @param previousName
+     * @return
+     */
+
+    public static Project findByPreviousPlaceOf(String previousOwnerLoginid, String previousName) {
+        List<Project> projects = find.where().ieq("previousOwnerLoginId", previousOwnerLoginid).ieq("previousName", previousName)
+            .setOrderBy("previousNameChangedTime desc").findList();
+        if(CollectionUtils.isEmpty(projects)){
+            return null;
+        }
+        return projects.get(0);  // Choose latest
+    }
+
+    public boolean hasOldPlace(){
+        if(StringUtils.isBlank(this.previousName)){
+            return false;
+        } else {
+            return true;
+        }
+    }
+
+    public String getOldPlace(){
+        if(this.previousOwnerLoginId == null){
+            return "";
+        }
+
+        if(hasOldPlace()){
+            return this.previousOwnerLoginId + "/" + this.previousName;
+        } else {
+            return "";
+        }
+    }
 }
 
app/utils/RedirectUtil.java (added)
+++ app/utils/RedirectUtil.java
@@ -0,0 +1,25 @@
+package utils;
+
+import actions.support.PathParser;
+import models.Project;
+import play.libs.F.Promise;
+import play.mvc.Http;
+import play.mvc.Result;
+
+import javax.annotation.Nonnull;
+
+import static play.mvc.Results.movedPermanently;
+import static play.mvc.Results.notFound;
+
+public class RedirectUtil {
+    public static Promise<Result> redirect(@Nonnull Project project) {
+        PathParser pathParser = new PathParser(Http.Context.current());
+        if(project.owner == null || project.name == null){
+            play.Logger.error("project.owner or project.name is null! " + project.owner + "/" + project.name);
+            Promise.pure((Result)notFound(ErrorViews.NotFound.render("error.notfound", project)));
+        }
+        String redirectPath = "/" + project.owner + "/" + project.name + "/" + pathParser.restOfPathExceptOwnerAndProjectName();
+        play.Logger.info(Http.Context.current().request().path() + " is redirected to " + redirectPath);
+        return Promise.pure(movedPermanently(redirectPath));
+    }
+}
app/views/project/setting.scala.html
--- app/views/project/setting.scala.html
+++ app/views/project/setting.scala.html
@@ -24,6 +24,12 @@
 @import utils.TemplateHelper._
 @import utils.TemplateHelper.Branches._
 
+@oldPlace() = @{
+    if(project.hasOldPlace){
+        Html("<div>" + Messages("project.previous.place", " <span style='color: red'>" + project.getOldPlace + "</span></div>"))
+    }
+}
+
 @projectLayout(message, project, utils.MenuType.PROJECT_SETTING) {
 @projectMenu(project, utils.MenuType.PROJECT_SETTING, "")
 <div class="page-wrap-outer">
@@ -58,7 +64,8 @@
                             <label for="project-name">@Messages("project.name.placeholder")</label>
                         </dt>
                         <dd>
-                            <input id="project-name" type="text" name="name" maxlength="250" value="@project.name">
+                            <input id="project-name" type="text" name="name" data-trigger="popover" data-content='@Messages("project.transfer.description6")' data-placement="left" data-trigger="focus" maxlength="250" value="@project.name">
+                            @oldPlace()<br/>
                         </dd>
                         <dt>
                             <label for="project-desc">@Messages("project.description.placeholder")</label>
@@ -168,6 +175,8 @@
 <script type="text/javascript">
     $(document).ready(function(){
         $yobi.loadModule("project.Setting");
+
+        $("#project-name").popover();
     });
 </script>
 
 
conf/evolutions/default/104.sql (added)
+++ conf/evolutions/default/104.sql
@@ -0,0 +1,15 @@
+# --- !Ups
+
+ALTER TABLE PROJECT ADD previous_name VARCHAR(255);
+ALTER TABLE PROJECT ADD previous_owner_login_id VARCHAR(255);
+ALTER TABLE PROJECT ADD previous_name_changed_time BIGINT;
+
+CREATE INDEX ix_project_previous_01 ON project(previous_owner_login_id, previous_name);
+
+# --- !Downs
+
+ALTER TABLE project DROP COLUMN IF EXISTS previous_name;
+ALTER TABLE project DROP COLUMN IF EXISTS previous_owner_login_id;
+ALTER TABLE project DROP COLUMN IF EXISTS previous_name_changed_time;
+
+DROP INDEX IF EXISTS ix_project_previous_01;
conf/messages
--- conf/messages
+++ conf/messages
@@ -572,6 +572,7 @@
 project.owner = Owner Name
 project.owner.invalidate = Owner information is not valid.
 project.owner.placeholder = Please select project ownership
+project.previous.place = This project was moved from {0}
 project.private = PRIVATE
 project.private.notice = Project access must be granted explicitly for each user. but basic information (name, description, etc.) can be exposed to public.
 project.projects = projects
@@ -603,6 +604,7 @@
 project.transfer.description3 = When it''s done, project''s current owner will be changed to a member of this project.
 project.transfer.description4 = The URL of all resources including issues, postings and others of this project will be changed.
 project.transfer.description5 = The URL of the repository of this project will be changed.
+project.transfer.description6 = Also, previous links are available. After changing it, during the first 24 hours, you can rename or transfer freely as many time as you want without braking previous links. The previous URL will be fixed and preserved for 24 hours.
 project.transfer.error = Not available user or group. Please check whether the user''s login id or the gorup''s name is correct.
 project.transfer.has.same.owner = This project already owned by the user or group.
 project.transfer.is.requested = The transfer request email has been sent.
conf/messages.ko-KR
--- conf/messages.ko-KR
+++ conf/messages.ko-KR
@@ -572,6 +572,7 @@
 project.owner = 프로젝트 소유자
 project.owner.invalidate = 소유자 정보가 유효하지 않습니다
 project.owner.placeholder = 프로젝트 소유자를 선택해주세요
+project.previous.place = 현재 프로젝트가 옛날에 있던 곳 {0}
 project.private = 비공개
 project.private.notice = 멤버로 등록한 사람만 접근할 수 있습니다. 단, 프로젝트 이름, 설명, 로고 등은 모든 사용자가 볼 수 있습니다.
 project.projects = 프로젝트
@@ -603,6 +604,7 @@
 project.transfer.description3 = 이관이 완료되면 현재 프로젝트 소유자는 프로젝트 멤버로 변경됩니다.
 project.transfer.description4 = 프로젝트의 이슈, 게시물 등의 URL이 변경됩니다.
 project.transfer.description5 = 프로젝트의 저장소 URL이 변경됩니다.
+project.transfer.description6 = 변경시 이전 링크로도 접근 가능합니다. 또한 최초 변경 이후 24시간 동안은 변경을 계속해도 최초 변경시점의 주소가 이전 주소로 계속 유지되므로 24시간 동안은 편하게 몇 번이고 변경하셔도 괜찮습니다.
 project.transfer.error = 해당하는 사용자 또는 그룹이 없습니다. 로그인 아이디 또는 그룹 이름을 확인해 주세요.
 project.transfer.has.same.owner = 해당 사용자 또는 그룹에 이미 속해있는 프로젝트입니다.
 project.transfer.is.requested = 이메일로 이관 요청을 보냈습니다.
Add a comment
List