Merge remote-tracking branch 'official/master' into saltation
[gitorious:taladars-gitorious-saltation.git] / app / models / project.rb
1 # encoding: utf-8
2 #--
3 #   Copyright (C) 2010 Marko Peltola <marko@markopeltola.com>
4 #   Copyright (C) 2010 Tero Hänninen <tero.j.hanninen@jyu.fi>
5 #   Copyright (C) 2009 Nokia Corporation and/or its subsidiary(-ies)
6 #   Copyright (C) 2007, 2008 Johan Sørensen <johan@johansorensen.com>
7 #   Copyright (C) 2008 David A. Cuadrado <krawek@gmail.com>
8 #   Copyright (C) 2008 Dag Odenhall <dag.odenhall@gmail.com>
9 #   Copyright (C) 2008 Tim Dysinger <tim@dysinger.net>
10 #   Copyright (C) 2008 Patrick Aljord <patcito@gmail.com>
11 #   Copyright (C) 2008 Tor Arne Vestbø <tavestbo@trolltech.com>
12 #   Copyright (C) 2009 Fabio Akita <fabio.akita@gmail.com>
13 #
14 #   This program is free software: you can redistribute it and/or modify
15 #   it under the terms of the GNU Affero General Public License as published by
16 #   the Free Software Foundation, either version 3 of the License, or
17 #   (at your option) any later version.
18 #
19 #   This program is distributed in the hope that it will be useful,
20 #   but WITHOUT ANY WARRANTY; without even the implied warranty of
21 #   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
22 #   GNU Affero General Public License for more details.
23 #
24 #   You should have received a copy of the GNU Affero General Public License
25 #   along with this program.  If not, see <http://www.gnu.org/licenses/>.
26 #++
27
28 class Project < ActiveRecord::Base
29   acts_as_taggable
30   include RecordThrottling
31   include UrlLinting
32   include Watchable
33
34   VISIBILITY_ALL            = 1
35   VISIBILITY_LOGGED_IN      = 2
36   VISIBILITY_COLLABORATORS  = 3
37   VISIBILITY_PUBLICS        = [VISIBILITY_ALL, VISIBILITY_LOGGED_IN]
38
39   belongs_to  :user
40   belongs_to  :owner, :polymorphic => true
41   has_many    :comments, :dependent => :destroy
42
43   has_many    :repositories, :order => "repositories.created_at asc",
44       :conditions => ["kind != ?", Repository::KIND_WIKI], :dependent => :destroy
45   has_one     :wiki_repository, :class_name => "Repository",
46     :conditions => ["kind = ?", Repository::KIND_WIKI], :dependent => :destroy
47   has_many :cloneable_repositories, :class_name => "Repository",
48      :conditions => ["kind != ?", Repository::KIND_TRACKING_REPO]
49   has_many    :events, :order => "created_at desc", :dependent => :destroy
50   has_many    :groups
51   belongs_to  :containing_site, :class_name => "Site", :foreign_key => "site_id"
52   has_many    :merge_request_statuses, :order => "id asc"
53   accepts_nested_attributes_for :merge_request_statuses, :allow_destroy => true
54
55   named_scope :visibility_all, :conditions => ["visibility = ?", VISIBILITY_ALL]
56   named_scope :visibility_publics, :conditions => ["visibility in (?)", VISIBILITY_PUBLICS]
57   default_scope :conditions => ["suspended_at is null"]
58
59   serialize :merge_request_custom_states, Array
60
61   attr_protected :owner_id, :user_id, :site_id
62
63   is_indexed :fields => ["title", "description", "slug"],
64     :concatenate => [
65       { :class_name => 'Tag',
66         :field => 'name',
67         :as => 'category',
68         :association_sql => "LEFT OUTER JOIN taggings ON taggings.taggable_id = projects.id " +
69                             "AND taggings.taggable_type = 'Project' LEFT OUTER JOIN tags ON taggings.tag_id = tags.id"
70       }],
71     :include => [{
72       :association_name => "user",
73       :field => "login",
74       :as => "user"
75     }]
76
77
78   URL_FORMAT_RE = /^(http|https|nntp):\/\//.freeze
79   NAME_FORMAT = /[a-z0-9_\-]+/.freeze
80   validates_presence_of :title, :user_id, :slug, :description, :owner_id
81   validates_uniqueness_of :slug, :case_sensitive => false
82   validates_format_of :slug, :with => /^#{NAME_FORMAT}$/i,
83     :message => I18n.t( "project.format_slug_validation")
84   validates_exclusion_of :slug, :in => Gitorious::Reservations.project_names
85   validates_format_of :home_url, :with => URL_FORMAT_RE,
86     :if => proc{|record| !record.home_url.blank? },
87     :message => I18n.t( "project.ssl_required")
88   validates_format_of :mailinglist_url, :with => URL_FORMAT_RE,
89     :if => proc{|record| !record.mailinglist_url.blank? },
90     :message => I18n.t( "project.ssl_required")
91   validates_format_of :bugtracker_url, :with => URL_FORMAT_RE,
92     :if => proc{|record| !record.bugtracker_url.blank? },
93     :message => I18n.t( "project.ssl_required")
94
95   before_validation :downcase_slug
96   after_create :create_wiki_repository
97   after_create :create_default_merge_request_statuses
98   after_create :add_as_favorite
99
100   throttle_records :create, :limit => 5,
101     :counter => proc{|record|
102       record.user.projects.count(:all, :conditions => ["created_at > ?", 5.minutes.ago])
103     },
104     :conditions => proc{|record| {:user_id => record.user.id} },
105     :timeframe => 5.minutes
106
107   LICENSES = [
108     'Academic Free License v3.0',
109     'MIT License',
110     'BSD License',
111     'Ruby License',
112     'GNU General Public License version 2(GPLv2)',
113     'GNU General Public License version 3 (GPLv3)',
114     'GNU Lesser General Public License (LGPL)',
115     'GNU Affero General Public License (AGPLv3)',
116     'Mozilla Public License 1.0 (MPL)',
117     'Mozilla Public License 1.1 (MPL 1.1)',
118     'Qt Public License (QPL)',
119     'Python License',
120     'zlib/libpng License',
121     'Apache License',
122     'Apple Public Source License',
123     'Perl Artistic License',
124     'Microsoft Permissive License (Ms-PL)',
125     'ISC License',
126     'Lisp Lesser License',
127     'Boost Software License',
128     'Public Domain',
129     'Other Open Source Initiative Approved License',
130     'Other/Proprietary License',
131     'Other/Multiple',
132     'None',
133   ]
134
135   def self.human_name
136     I18n.t("activerecord.models.project")
137   end
138
139   def visibility_human_name
140     return I18n.t("visibility.private_by_project")   if visibility_collaborators?
141     return I18n.t("visibility.logged_in")            if visibility_logged_in?
142     return I18n.t("visibility.all")                  if visibility_all?
143   end
144
145   def self.per_page() 20 end
146
147   def self.top_tags(limit = 10)
148     tag_counts(:limit => limit, :order => "count desc")
149   end
150
151   # Returns the projects limited by +limit+ who has the most activity within
152   # the +cutoff+ period
153   def self.most_active_recently(logged_in, limit = 10, number_of_days = 3)
154     Rails.cache.fetch("projects:most_active_recently:#{limit}:#{number_of_days}",
155         :expires_in => 30.minutes) do
156       scope = logged_in ? VISIBILITY_PUBLICS : [VISIBILITY_ALL]
157       find(:all, :joins => :events, :limit => limit,
158         :select => 'distinct projects.*, count(events.id) as event_count',
159         :order => "event_count desc", :group => "projects.id",
160         :conditions => ["events.created_at > :days_ago
161                          and visibility in (:proj_vis_scope)",
162                        {:days_ago       => number_of_days.days.ago,
163                         :proj_vis_scope => scope}])
164     end
165   end
166
167   # This is horrible
168   def self.projects_for(user)
169     p = user.projects
170     user.groups.each do |g|
171       g.projects.each do |gp|
172         p << gp if gp.collaborator?(user)
173       end
174     end
175     p
176   end
177
178   def viewers
179     v = []
180     case owner
181     when User
182       v << self.owner
183     when Group
184       v += owner.members
185     end
186     self.repositories.mainlines.each do |repo|
187       v += repo.viewers
188     end
189     v.uniq
190   end
191
192   # Returns all repos of this project that the given user
193   # has view right to.
194   def repositories_viewable_by(user)
195     # The delete_if approach isn't very pretty..
196     self.repositories.mainlines.delete_if { |r| !r.can_be_viewed_by?(user) }
197   end
198
199   def recently_updated_group_repository_clones(user, limit = 5)
200     self.repositories.by_groups.find(:all, :limit => limit,
201       :order => "last_pushed_at desc").delete_if { |r| !r.can_be_viewed_by?(user) }
202   end
203
204   def recently_updated_user_repository_clones(user, limit = 5)
205     self.repositories.by_users.find(:all, :limit => limit,
206       :order => "last_pushed_at desc").delete_if { |r| !r.can_be_viewed_by?(user) }
207   end
208
209   def to_param
210     slug
211   end
212
213   def to_param_with_prefix
214     to_param
215   end
216
217   def site
218     containing_site || Site.default
219   end
220
221   def admin?(candidate)
222     case owner
223     when User
224       candidate == self.owner
225     when Group
226       owner.admin?(candidate)
227     end
228   end
229
230   def member?(candidate)
231     case owner
232     when User
233       candidate == self.owner
234     when Group
235       owner.member?(candidate)
236     end
237   end
238
239   def committer?(candidate)
240     owner == User ? owner == candidate : owner.committer?(candidate)
241   end
242
243   def collaborator?(candidate)
244     repositories.mainlines.each do |repo|
245       return true if repo.collaborator?(candidate)
246     end
247     member?(candidate)
248   end
249
250   def visibility_all?
251     self.visibility == VISIBILITY_ALL
252   end
253
254   def visibility_logged_in?
255     self.visibility == VISIBILITY_LOGGED_IN
256   end
257
258   def visibility_collaborators?
259     self.visibility == VISIBILITY_COLLABORATORS
260   end
261
262   def visibility_publics?
263     VISIBILITY_PUBLICS.include? self.visibility
264   end
265
266   def can_be_viewed_by?(candidate)
267     return true if self.visibility_all?
268     return true if self.visibility_logged_in? && candidate != :false
269     return true if self.visibility_collaborators? && collaborator?(candidate)
270     return false
271   end
272
273   def self.visibility_publics_or_all(logged_in)
274     if logged_in    
275       self.visibility_publics
276     else
277       self.visibility_all
278     end
279   end
280
281   def owned_by_group?
282     owner === Group
283   end
284
285   def can_be_deleted_by?(candidate)
286     admin?(candidate) && repositories.clones.count == 0
287   end
288
289   def tag_list=(tag_list)
290     tag_list.gsub!(/,\s*/, " ")
291     super
292   end
293
294   def home_url=(url)
295     self[:home_url] = clean_url(url)
296   end
297
298   def mailinglist_url=(url)
299     self[:mailinglist_url] = clean_url(url)
300   end
301
302   def bugtracker_url=(url)
303     self[:bugtracker_url] = clean_url(url)
304   end
305
306   def stripped_description
307     description.gsub(/<\/?[^>]*>/, "")
308     # sanitizer = HTML::WhiteListSanitizer.new
309     # sanitizer.sanitize(description, :tags => %w(str), :attributes => %w(class))
310   end
311
312   def descriptions_first_paragraph
313     description[/^([^\n]+)/, 1]
314   end
315
316   def to_xml(opts = {})
317     info = Proc.new { |options|
318       builder = options[:builder]
319       builder.owner(owner.to_param, :kind => (owned_by_group? ? "Team" : "User"))
320
321       builder.repositories(:type => "array") do |repos|
322         builder.mainlines :type => "array" do
323           repositories.mainlines.each { |repo|
324             builder.repository do
325               builder.id repo.id
326               builder.name repo.name
327               builder.owner repo.owner.to_param, :kind => (repo.owned_by_group? ? "Team" : "User")
328               builder.clone_url repo.clone_url
329             end
330           }
331         end
332         builder.clones :type => "array" do
333           repositories.clones.each { |repo|
334             builder.repository do
335               builder.id repo.id
336               builder.name repo.name
337               builder.owner repo.owner.to_param, :kind => (repo.owned_by_group? ? "Team" : "User")
338               builder.clone_url repo.clone_url
339             end
340           }
341         end
342       end
343     }
344     super({
345       :procs => [info],
346       :only => [:slug, :title, :description, :license, :home_url, :wiki_enabled,
347                 :created_at, :bugtracker_url, :mailinglist_url, :bugtracker_url],
348     }.merge(opts))
349   end
350
351   def create_event(action_id, target, user, data = nil, body = nil, date = Time.now.utc)
352     event = events.create({
353         :action => action_id,
354         :target => target,
355         :user => user,
356         :body => body,
357         :data => data,
358         :created_at => date
359       })
360   end
361
362   def new_event_required?(action_id, target, user, data)
363     events_count = events.count(:all, :conditions => [
364       "action = :action_id AND target_id = :target_id AND target_type = :target_type AND user_id = :user_id and data = :data AND created_at > :date_threshold",
365       {
366         :action_id => action_id,
367         :target_id => target.id,
368         :target_type => target.class.name,
369         :user_id => user.id,
370         :data => data,
371         :date_threshold => 1.hour.ago
372       }])
373     return events_count < 1
374   end
375
376   def breadcrumb_parent
377     nil
378   end
379
380   def change_owner_to(another_owner)
381     unless owned_by_group?
382       self.owner = another_owner
383       self.wiki_repository.owner = another_owner
384
385       repositories.mainlines.each {|repo|
386         c = repo.committerships.create!(:committer => another_owner,:creator_id => self.owner_id_was)
387         c.build_permissions(:review, :commit, :admin)
388         c.save!
389       }
390     end
391   end
392
393   # TODO: Add tests
394   def oauth_consumer
395     @oauth_consumer ||= OAuth::Consumer.new(oauth_signoff_key, oauth_signoff_secret, oauth_consumer_options)
396   end
397
398   def oauth_consumer_options
399     result = {:site => oauth_signoff_site}
400     unless oauth_path_prefix.blank?
401       %w(request_token authorize access_token).each do |p|
402         result[:"#{p}_path"] = File.join("/", oauth_path_prefix, p)
403       end
404     end
405     result
406   end
407
408   def oauth_settings=(options)
409     self.merge_requests_need_signoff = !options[:site].blank?
410     self.oauth_path_prefix    = options[:path_prefix]
411     self.oauth_signoff_key    = options[:signoff_key]
412     self.oauth_signoff_secret = options[:signoff_secret]
413     self.oauth_signoff_site   = options[:site]
414   end
415
416   def oauth_settings
417     {
418       :path_prefix    => oauth_path_prefix,
419       :signoff_key    => oauth_signoff_key,
420       :site           => oauth_signoff_site,
421       :signoff_secret => oauth_signoff_secret
422     }
423   end
424
425   def search_repositories(term)
426     Repository.title_search(term, "project_id", id)
427   end
428
429   def wiki_permissions
430     wiki_repository.wiki_permissions
431   end
432
433   def wiki_permissions=(perms)
434     wiki_repository.wiki_permissions = perms
435   end
436
437   # Returns a String representation of the merge request states
438   def merge_request_states
439     (merge_request_custom_states || merge_request_default_states).join("\n")
440   end
441
442   def merge_request_states=(s)
443     self.merge_request_custom_states = s.split("\n").collect(&:strip)
444   end
445
446
447   def merge_request_fixed_states
448     ['Merged','Rejected']
449   end
450
451   def merge_request_default_states
452     ['Open','Closed','Verifying']
453   end
454
455   def has_custom_merge_request_states?
456     !merge_request_custom_states.blank?
457   end
458
459   def default_merge_request_status_id
460     if status = merge_request_statuses.default
461       status.id
462     end
463   end
464
465   def default_merge_request_status_id=(status_id)
466     merge_request_statuses.each do |status|
467       if status.id == status_id.to_i
468         status.update_attribute(:default, true)
469       else
470         status.update_attribute(:default, false)
471       end
472     end
473   end
474
475   def suspended?
476     !suspended_at.nil?
477   end
478
479   def suspend!
480     self.suspended_at = Time.now
481   end
482
483   protected
484     def create_wiki_repository
485       self.wiki_repository = Repository.create!({
486         :user => self.user,
487         :name => self.slug + Repository::WIKI_NAME_SUFFIX,
488         :kind => Repository::KIND_WIKI,
489         :project => self,
490         :owner => self.owner,
491       })
492     end
493
494     def create_default_merge_request_statuses
495       MergeRequestStatus.create_defaults_for_project(self)
496     end
497
498     def downcase_slug
499       slug.downcase! if slug
500     end
501
502     def add_as_favorite
503       watched_by!(self.user)
504     end
505 end