Project

General

Profile

Feature #34981 » identifiers.diff

Pavel Rosický, 2021-04-03 15:13

View differences:

app/controllers/application_controller.rb
325 325
  def find_project(project_id=params[:id])
326 326
    @project = Project.find(project_id)
327 327
  rescue ActiveRecord::RecordNotFound
328
    render_404
328
    project_alias = Project.find_alias(project_id)
329
    if @project = project_alias&.project
330
      flash[:warning] = l(:warning_identifier_renamed, alias: project_alias.identifier, identifier: @project.identifier)
331
    else
332
      render_404
333
    end
329 334
  end
330 335

  
331 336
  # Find project of id params[:project_id]
app/controllers/repositories_controller.rb
333 333
  REV_PARAM_RE = %r{\A[a-f0-9]*\Z}i
334 334

  
335 335
  def find_project_repository
336
    @project = Project.find(params[:id])
336
    return unless find_project
337 337
    if params[:repository_id].present?
338 338
      @repository = @project.repositories.find_by_identifier_param(params[:repository_id])
339
      unless @repository
340
        repository_alias = @project.repositories.find_alias(params[:repository_id])
341
        @repository = repository_alias&.repository
342
        if @repository
343
          flash[:warning] = l(:warning_identifier_renamed, alias: repository_alias.identifier, identifier: @repository.identifier)
344
        end
345
      end
339 346
    else
340 347
      @repository = @project.repository
341 348
    end
app/models/project.rb
52 52
  has_many :repositories, :dependent => :destroy
53 53
  has_many :changesets, :through => :repository
54 54
  has_one :wiki, :dependent => :destroy
55
  has_one :project_identifier, :primary_key => 'identifier', :foreign_key => 'identifier'
56
  has_many :project_identifiers, :dependent => :destroy
55 57
  # Custom field for the project issues
56 58
  has_and_belongs_to_many :issue_custom_fields,
57 59
                          lambda {order(:position)},
......
72 74
                :author => nil
73 75

  
74 76
  validates_presence_of :name, :identifier
75
  validates_uniqueness_of :identifier, :if => proc {|p| p.identifier_changed?}, :case_sensitive => true
77
  validates :identifier, :if => proc {|p| p.identifier_changed? && p.identifier.present?}, :project_identifier_uniqueness => true
76 78
  validates_length_of :name, :maximum => 255
77 79
  validates_length_of :homepage, :maximum => 255
78 80
  validates_length_of :identifier, :maximum => IDENTIFIER_MAX_LENGTH
79 81
  # downcase letters, digits, dashes but not digits only
80 82
  validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/,
81 83
                      :if => proc {|p| p.identifier_changed?}
84
  after_validation :reset_invalid_identifier
85

  
82 86
  # reserved words
83 87
  validates_exclusion_of :identifier, :in => %w(new)
84 88
  validate :validate_parent
85 89

  
90
  after_save :update_identifier,
91
             :if => proc {|project| project.saved_change_to_identifier? && project.identifier.present? && !project.new_record?}
86 92
  after_save :update_inherited_members,
87 93
             :if => proc {|project| project.saved_change_to_inherit_members?}
88 94
  after_save :remove_inherited_member_roles, :add_inherited_member_roles,
......
137 143
    end
138 144
  end
139 145

  
140
  def identifier=(identifier)
141
    super unless identifier_frozen?
142
  end
143

  
144
  def identifier_frozen?
145
    errors[:identifier].blank? && !(new_record? || identifier.blank?)
146
  end
147 146

  
148 147
  # returns latest created projects
149 148
  # non public projects will be returned only if user is a member of those
......
350 349
    end
351 350
  end
352 351

  
352
  def self.find_alias(*args)
353
    if args.first && args.first.is_a?(String) && !/^\d*$/.match?(args.first)
354
      scope = ProjectIdentifier
355
      scope = scope.where(project_id: current_scope) if scope_attributes?
356
      scope.find_by_identifier(*args)
357
    end
358
  end
359

  
353 360
  def self.find_by_param(*args)
354 361
    self.find(*args)
355 362
  end
......
993 1000
    end
994 1001
  end
995 1002

  
1003
  def update_identifier
1004
    self.project_identifiers << project_identifiers.build(:identifier => identifier, :project_id => self.id)
1005
  end
1006

  
996 1007
  def remove_inherited_member_roles
997 1008
    member_roles = MemberRole.where(:member_id => membership_ids).to_a
998 1009
    member_role_ids = member_roles.map(&:id)
......
1032 1043
    end
1033 1044
  end
1034 1045

  
1046
  def reset_invalid_identifier
1047
    if errors[:identifier].present?
1048
      self.identifier = identifier_was
1049
    end
1050
  end
1051

  
1035 1052
  # Copies wiki from +project+
1036 1053
  def copy_wiki(project)
1037 1054
    # Check that the source project has a wiki first
app/models/project_identifier.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-2021  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
class ProjectIdentifier < ActiveRecord::Base
21
  belongs_to :project
22

  
23
  validates :identifier, :uniqueness => { :case_sensitive => true }, :presence => true
24
  validates :project_id, :presence => true
25
end
app/models/repository.rb
29 29
  belongs_to :project
30 30
  has_many :changesets, lambda{order("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC")}
31 31
  has_many :filechanges, :class_name => 'Change', :through => :changesets
32
  has_one :repository_identifier, :primary_key => 'identifier', :foreign_key => 'identifier'
33
  has_many :repository_identifiers, :dependent => :destroy
32 34

  
33 35
  serialize :extra_info
34 36

  
35 37
  before_validation :normalize_identifier
36 38
  before_save :check_default
39
  after_save :update_identifier,
40
    :if => proc {|repository| repository.saved_change_to_identifier? && !repository.new_record? && repository.project}
37 41

  
38 42
  # Raw SQL to delete changesets and changes in the database
39 43
  # has_many :changesets, :dependent => :destroy is too slow for big repositories
......
43 47
  validates_length_of :password, :maximum => 255, :allow_nil => true
44 48
  validates_length_of :root_url, :url, maximum: 255
45 49
  validates_length_of :identifier, :maximum => IDENTIFIER_MAX_LENGTH, :allow_blank => true
46
  validates_uniqueness_of :identifier, :scope => :project_id, :case_sensitive => true
50
  validates :identifier, :if => proc { |r| r.identifier_changed? }, :repository_identifier_uniqueness => true
47 51
  validates_exclusion_of :identifier, :in => %w(browse show entry raw changes annotate diff statistics graph revisions revision)
48 52
  # donwcase letters, digits, dashes, underscores but not digits only
49 53
  validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :allow_blank => true
50 54
  # Checks if the SCM is enabled when creating a repository
51 55
  validate :repo_create_validation, :on => :create
52 56
  validate :validate_repository_path
57
  after_validation :reset_invalid_identifier
53 58

  
54 59
  safe_attributes(
55 60
    'identifier',
......
124 129
    end
125 130
  end
126 131

  
127
  def identifier=(identifier)
128
    super unless identifier_frozen?
129
  end
130

  
131
  def identifier_frozen?
132
    errors[:identifier].blank? && !(new_record? || identifier.blank?)
133
  end
134

  
135 132
  def identifier_param
136 133
    if identifier.present?
137 134
      identifier
......
150 147
    end
151 148
  end
152 149

  
150
  def reset_invalid_identifier
151
    if errors[:identifier].present?
152
      self.identifier = identifier_was
153
    end
154
  end
155

  
153 156
  def self.find_by_identifier_param(param)
154 157
    if /^\d+$/.match?(param.to_s)
155 158
      find_by_id(param)
......
158 161
    end
159 162
  end
160 163

  
164
  def self.find_alias(param)
165
    if !/^\d+$/.match?(param.to_s)
166
      scope = RepositoryIdentifier
167
      scope = scope.where(repository_id: current_scope) if scope_attributes?
168
      scope.find_by_identifier(param)
169
    end
170
  end
171

  
161 172
  # TODO: should return an empty hash instead of nil to avoid many ||{}
162 173
  def extra_info
163 174
    h = read_attribute(:extra_info)
......
481 492
    self.identifier = identifier.to_s.strip
482 493
  end
483 494

  
495
  def update_identifier
496
    self.repository_identifiers << repository_identifiers.build(:project_id => project.id, :identifier => identifier, :repository_id => self.id)
497
  end
498

  
484 499
  def check_default
485 500
    if !is_default? && set_as_default?
486 501
      self.is_default = true
app/models/repository_identifier.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-2021  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
class RepositoryIdentifier < ActiveRecord::Base
21
  belongs_to :repository
22
  belongs_to :project
23

  
24
  validates :identifier, :uniqueness => { :scope => :project_id, :case_sensitive => true }
25
  validates :repository_id, :presence => true
26
end
app/views/projects/_form.html.erb
5 5
<p><%= f.text_field :name, :required => true, :size => 60 %></p>
6 6

  
7 7
<p><%= f.text_area :description, :rows => 8, :class => 'wiki-edit' %></p>
8
<p><%= f.text_field :identifier, :required => true, :size => 60, :disabled => @project.identifier_frozen?, :maxlength => Project::IDENTIFIER_MAX_LENGTH %>
9
<% unless @project.identifier_frozen? %>
8
<p>
9
  <%= f.text_field :identifier, :required => true, :size => 60, :maxlength => Project::IDENTIFIER_MAX_LENGTH %>
10 10
  <em class="info"><%= l(:text_length_between, :min => 1, :max => Project::IDENTIFIER_MAX_LENGTH) %> <%= l(:text_project_identifier_info).html_safe %></em>
11
<% end %></p>
11
</p>
12 12
<p><%= f.text_field :homepage, :size => 60 %></p>
13 13
<p>
14 14
  <%= f.check_box :is_public %>
......
44 44
<% end %>
45 45
<!--[eoform:project]-->
46 46

  
47
<% unless @project.identifier_frozen? %>
48
  <% content_for :header_tags do %>
49
    <%= javascript_include_tag 'project_identifier' %>
50
  <% end %>
47
<% content_for :header_tags do %>
48
  <%= javascript_include_tag 'project_identifier' %>
51 49
<% end %>
52 50

  
53 51
<% if !User.current.admin? && @project.inherit_members? && @project.parent && User.current.member_of?(@project.parent) %>
app/views/repositories/_form.html.erb
10 10

  
11 11
<p><%= f.check_box :is_default, :label => :field_repository_is_default %></p>
12 12
<p>
13
<%= f.text_field :identifier, :disabled => @repository.identifier_frozen? %>
14
<% unless @repository.identifier_frozen? %>
13
  <%= f.text_field :identifier %>
15 14
  <em class="info">
16 15
    <%= l(:text_length_between, :min => 1, :max => Repository::IDENTIFIER_MAX_LENGTH) %> <%= l(:text_repository_identifier_info).html_safe %>
17 16
  </em>
18
<% end %>
19 17
</p>
20 18

  
21 19
<% button_disabled = true %>
config/locales/en.yml
116 116
        too_short: "is too short (minimum is %{count} characters)"
117 117
        wrong_length: "is the wrong length (should be %{count} characters)"
118 118
        taken: "has already been taken"
119
        used: "has already been used once"
119 120
        not_a_number: "is not a number"
120 121
        not_a_date: "is not a valid date"
121 122
        greater_than: "must be greater than %{count}"
......
1199 1200
  text_tip_issue_begin_day: issue beginning this day
1200 1201
  text_tip_issue_end_day: issue ending this day
1201 1202
  text_tip_issue_begin_end_day: issue beginning and ending this day
1202
  text_project_identifier_info: 'Only lower case letters (a-z), numbers, dashes and underscores are allowed.<br />Once saved, the identifier cannot be changed.'
1203
  text_project_identifier_info: 'Only lower case letters (a-z), numbers, dashes and underscores are allowed.'
1203 1204
  text_caracters_maximum: "%{count} characters maximum."
1204 1205
  text_caracters_minimum: "Must be at least %{count} characters long."
1205 1206
  text_characters_must_contain: "Must contain %{character_classes}."
......
1312 1313
  description_all_columns: All Columns
1313 1314
  description_issue_category_reassign: Choose issue category
1314 1315
  description_wiki_subpages_reassign: Choose new parent page
1315
  text_repository_identifier_info: 'Only lower case letters (a-z), numbers, dashes and underscores are allowed.<br />Once saved, the identifier cannot be changed.'
1316
  text_repository_identifier_info: 'Only lower case letters (a-z), numbers, dashes and underscores are allowed.'
1316 1317
  text_login_required_html: When not requiring authentication, public projects and their contents are openly available on the network. You can <a href="%{anonymous_role_path}">edit the applicable permissions</a>.
1317 1318
  label_login_required_yes: "Yes"
1318 1319
  label_login_required_no: "No, allow anonymous access to public projects"
......
1356 1357

  
1357 1358
  text_user_destroy_confirmation: "Are you sure you want to delete this user and remove all references to them? This cannot be undone. Often, locking a user instead of deleting them is the better solution. To confirm, please enter their login (%{login}) below."
1358 1359
  text_project_destroy_enter_identifier: "To confirm, please enter the project's identifier (%{identifier}) below."
1360

  
1361
  warning_identifier_renamed: 'The identifier has been renamed from "%{alias}" to "%{identifier}", please change your url'
db/migrate/20210402143502_create_identifiers.rb
1
class CreateIdentifiers < ActiveRecord::Migration[5.2]
2
  def change
3
    create_table :project_identifiers do |t|
4
      t.references :project, :null => false, :index => true
5
      t.string :identifier, :null => false, :index => { :unique => true }
6
      t.timestamps
7
    end
8

  
9
    create_table :repository_identifiers do |t|
10
      t.references :project, :null => false
11
      t.references :repository, :null => false, :index => true
12
      t.string :identifier, :null => false
13
      t.timestamps
14
    end
15
    add_index :repository_identifiers, [:project_id, :identifier], :unique => true
16
  end
17
end
db/migrate/20210402153405_migrate_identifiers.rb
1
class MigrateIdentifiers < ActiveRecord::Migration[5.2]
2
  def up
3
    Project.where.not(identifier: nil).find_each do |project|
4
      ProjectIdentifier.create!(project: project, identifier: project.identifier)
5
    end
6

  
7
    Repository.where.not(identifier: nil).find_each do |repository|
8
      RepositoryIdentifier.create!(project: repository.project, respository: repository, identifier: repository.identifier)
9
    end
10
  end
11
end
lib/redmine/core_ext/active_record.rb
27 27
    end
28 28
  end
29 29
end
30

  
31
class IdentifierUniquenessValidator < ActiveRecord::Validations::UniquenessValidator
32
  include Redmine::I18n
33

  
34
  def initialize(options)
35
    options[:case_sensitive] = true
36
    super
37
  end
38
end
39

  
40
class ProjectIdentifierUniquenessValidator < IdentifierUniquenessValidator
41
  def validate_each(record, attribute, value)
42
    super
43
    if record.errors[attribute].empty?
44
      identifier = ProjectIdentifier.new
45
      ProjectIdentifier.validators_on(:identifier).each do |validator|
46
        validator.validate_each(identifier, :identifier, value)
47
      end
48
      if identifier.errors.any?
49
        record.errors.add attribute, l(:used, :scope => [:activerecord, :errors, :messages])
50
      end
51
    end
52
  end
53
end
54

  
55
class RepositoryIdentifierUniquenessValidator < IdentifierUniquenessValidator
56
  def initialize(options)
57
    options[:scope] = :project_id
58
    super
59
  end
60

  
61
  def validate_each(record, attribute, value)
62
    super
63
    if record.errors[attribute].empty?
64
      identifier = RepositoryIdentifier.new(project: record.project)
65
      RepositoryIdentifier.validators_on(:identifier).each do |validator|
66
        validator.validate_each(identifier, :identifier, value)
67
      end
68
      if identifier.errors.any?
69
        record.errors.add attribute, l(:used, :scope => [:activerecord, :errors, :messages])
70
      end
71
    end
72
  end
73
end
test/functional/issues_controller_test.rb
119 119
    end
120 120
  end
121 121

  
122
  def test_index_find_previous_project_identifier
123
    Project.where(:id => 1).update_all(:identifier => 'new_identifier')
124
    ProjectIdentifier.create(project_id: 1, identifier: 'previous_identifier')
125

  
126
    get(:index, :params => {:project_id => 'new_identifier'})
127
    assert_response :success
128
    assert_select '.flash.warning', :count => 0
129

  
130
    get(:index, :params => {:project_id => 'previous_identifier'})
131
    assert_response :success
132
    assert_select '.flash.warning', :text => I18n.t(:warning_identifier_renamed, alias: 'previous_identifier', identifier: 'new_identifier')
133

  
134
    get(:index, :params => {:project_id => 'unknown_identifier'})
135
    assert_response 404
136
  end
137

  
122 138
  def test_index_should_list_issues_of_closed_subprojects
123 139
    @request.session[:user_id] = 1
124 140
    project = Project.find(1)
test/functional/repositories_controller_test.rb
236 236
    assert_select 'table.changesets'
237 237
  end
238 238

  
239
  def test_revisions_with_previous_repository_identifier
240
    repository = Repository::Subversion.create!(:project_id => 1, :identifier => 'new_identifier', :url => 'file:///foo')
241
    RepositoryIdentifier.create(project_id: 1, identifier: 'previous_identifier', repository_id: repository.id)
242
    get(
243
      :revisions,
244
      :params => {
245
        :id => 1,
246
        :repository_id => 'new_identifier'
247
      }
248
    )
249
    assert_response :success
250
    assert_select '.flash.warning', :count => 0
251

  
252
    get(
253
      :revisions,
254
      :params => {
255
        :id => 1,
256
        :repository_id => 'previous_identifier'
257
      }
258
    )
259
    assert_response :success
260
    assert_select '.flash.warning', :text => I18n.t(:warning_identifier_renamed, alias: 'previous_identifier', identifier: 'new_identifier')
261

  
262
    get(
263
      :revisions,
264
      :params => {
265
        :id => 1,
266
        :repository_id => 'unknown_identifier'
267
      }
268
    )
269
    assert_response 404
270
  end
271

  
239 272
  def test_revisions_for_other_repository
240 273
    repository = Repository::Subversion.create!(:project_id => 1, :identifier => 'foo', :url => 'file:///foo')
241 274
    get(
test/unit/project_test.rb
132 132
    end
133 133
  end
134 134

  
135
  def test_identifier_should_not_be_frozen_for_a_new_project
136
    assert_equal false, Project.new.identifier_frozen?
137
  end
138

  
139
  def test_identifier_should_not_be_frozen_for_a_saved_project_with_blank_identifier
140
    Project.where(:id => 1).update_all(["identifier = ''"])
141
    assert_equal false, Project.find(1).identifier_frozen?
135
  def test_identifier_should_validate_used_identifiers
136
    p = Project.find(1)
137
    p.identifier = 'test'
138
    assert p.save
139
    p.identifier = 'test2'
140
    assert p.save
141
    p.identifier = 'test'
142
    assert !p.save
143
    assert p.errors['identifier'].present?
142 144
  end
143 145

  
144
  def test_identifier_should_be_frozen_for_a_saved_project_with_valid_identifier
145
    assert_equal true, Project.find(1).identifier_frozen?
146
  end
147 146

  
148 147
  def test_to_param_should_be_nil_for_new_records
149 148
    project = Project.new
test/unit/repository_git_test.rb
46 46
  end
47 47

  
48 48
  def test_nondefault_repo_with_blank_identifier_destruction
49
    Repository.delete_all
49
    Repository.destroy_all
50 50

  
51 51
    repo1 =
52 52
      Repository::Git.new(
test/unit/repository_test.rb
114 114
    assert !r.save
115 115
  end
116 116

  
117
  def test_identifier_should_validate_used_identifiers
118
    r = Repository::Subversion.new(:project_id => 3, :identifier => 'test', :url => 'file:///bar')
119
    assert r.save
120
    r.identifier = 'test2'
121
    assert r.save
122
    r.identifier = 'test'
123
    assert !r.save
124
    assert r.errors['identifier'].present?
125
  end
126

  
127
  def test_identifier_should_allow_same_identifier_on_multiple_projects
128
    r = Repository::Subversion.new(:project_id => 3, :identifier => 'test', :url => 'file:///bar')
129
    assert r.save
130
    r = Repository::Subversion.new(:project_id => 4, :identifier => 'test', :url => 'file:///bar')
131
    assert r.save
132
  end
133

  
117 134
  def test_first_repository_should_be_set_as_default
118 135
    repository1 =
119 136
      Repository::Subversion.
......
179 196
    assert r.save
180 197
  end
181 198

  
182
  def test_identifier_should_not_be_frozen_for_a_new_repository
183
    assert_equal false, Repository.new.identifier_frozen?
184
  end
185

  
186
  def test_identifier_should_not_be_frozen_for_a_saved_repository_with_blank_identifier
187
    Repository.where(:id => 10).update_all(["identifier = ''"])
188
    assert_equal false, Repository.find(10).identifier_frozen?
189
  end
190

  
191
  def test_identifier_should_be_frozen_for_a_saved_repository_with_valid_identifier
192
    Repository.where(:id => 10).update_all(["identifier = 'abc123'"])
193
    assert_equal true, Repository.find(10).identifier_frozen?
194
  end
195

  
196
  def test_identifier_should_not_accept_change_if_frozen
197
    r = Repository.new(:identifier => 'foo')
198
    r.stubs(:identifier_frozen?).returns(true)
199

  
200
    r.identifier = 'bar'
201
    assert_equal 'foo', r.identifier
202
  end
203

  
204
  def test_identifier_should_accept_change_if_not_frozen
205
    r = Repository.new(:identifier => 'foo')
206
    r.stubs(:identifier_frozen?).returns(false)
207

  
208
    r.identifier = 'bar'
209
    assert_equal 'bar', r.identifier
210
  end
211 199

  
212 200
  def test_destroy
213 201
    repository = Repository.find(10)
    (1-1/1)