diff --git a/Gemfile b/Gemfile index 0947b25b419..f9f5fbda895 100644 --- a/Gemfile +++ b/Gemfile @@ -49,6 +49,7 @@ gem 'warden-basic_auth', '~> 0.2.1' gem 'rails_autolink', '~> 1.1.6' gem 'will_paginate', '~> 3.0' gem 'acts_as_list', '~> 0.3.0' +gem 'acts_as_countable', git: "https://github.com/finnlabs/acts_as_countable.git", ref: '2471265' gem 'friendly_id', '~> 5.1.0' @@ -93,7 +94,8 @@ gem 'syck', platforms: [:mri, :mingw, :x64_mingw], require: false gem 'gon', '~> 4.0' # catch exceptions and send them to any airbrake compatible backend -gem 'airbrake', '~> 4.1.0' +# don't require by default, instead load on-demand when actually configured +gem 'airbrake', '~> 4.1.0', require: false group :production do # we use dalli as standard memcache client diff --git a/Gemfile.lock b/Gemfile.lock index 6334027bf06..f434cd2e699 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,6 +14,13 @@ GIT capybara rspec +GIT + remote: https://github.com/finnlabs/acts_as_countable.git + revision: 2471265a14b011d566d3865c1c52516f970c02e1 + ref: 2471265 + specs: + acts_as_countable (0.1.0) + GIT remote: https://github.com/finnlabs/rack-protection.git revision: 5a7d1bd2f05ca75faf7909c8cc978732a0080898 @@ -30,7 +37,7 @@ GIT GIT remote: https://github.com/opf/openproject-translations.git - revision: fa34ac5106d864158dc74231e460c5ec5055428b + revision: 89f7a195cc3b2bc71e6fc35586b61951da4f7e8c branch: feature/rails4 specs: openproject-translations (4.3.0) @@ -234,7 +241,7 @@ GEM hashie (3.4.1) hike (1.2.3) htmldiff (0.0.1) - i18n (0.6.11) + i18n (0.7.0) ice_nine (0.11.1) inflecto (0.0.2) interception (0.5) @@ -256,7 +263,7 @@ GEM mini_portile (0.6.2) minitest (4.7.5) mixlib-shellout (2.1.0) - multi_json (1.11.0) + multi_json (1.11.2) multi_test (0.1.2) multi_xml (0.5.5) mysql2 (0.3.18) @@ -302,7 +309,7 @@ GEM railties (>= 3.1, < 5.0) rabl (0.9.3) activesupport (>= 2.3.14) - rack (1.5.4) + rack (1.5.5) rack-accept (0.4.5) rack (>= 0.4) rack-attack (4.2.0) @@ -415,12 +422,12 @@ GEM simplecov-html (~> 0.7.1) simplecov-html (0.7.1) slop (3.6.0) - sprockets (2.12.3) + sprockets (2.12.4) hike (~> 1.2) multi_json (~> 1.0) rack (~> 1.0) tilt (~> 1.1, != 1.3.0) - sprockets-rails (2.3.1) + sprockets-rails (2.3.2) actionpack (>= 3.0) activesupport (>= 3.0) sprockets (>= 2.8, < 4.0) @@ -466,6 +473,7 @@ DEPENDENCIES activerecord-jdbcpostgresql-adapter activerecord-session_store activerecord-tableless (~> 1.0) + acts_as_countable! acts_as_list (~> 0.3.0) airbrake (~> 4.1.0) autoprefixer-rails @@ -561,4 +569,4 @@ DEPENDENCIES will_paginate (~> 3.0) BUNDLED WITH - 1.10.5 + 1.10.6 diff --git a/app/assets/javascripts/repository_navigation.js b/app/assets/javascripts/repository_navigation.js index 3953e5c8631..0e7842c2890 100644 --- a/app/assets/javascripts/repository_navigation.js +++ b/app/assets/javascripts/repository_navigation.js @@ -26,38 +26,48 @@ // See doc/COPYRIGHT.rdoc for more details. //++ -Event.observe(window,'load',function() { - /* - If we're viewing a tag or branch, don't display it in the - revision box - */ - var branch_selected = $('branch') && $('rev').getValue() == $('branch').getValue(); - var tag_selected = $('tag') && $('rev').getValue() == $('tag').getValue(); - if (branch_selected || tag_selected) { - $('rev').setValue(''); - } +(function($) { + $(function() { + var revision = $('#revision-identifier-input'), + form = revision.closest('form'), + tag = $('#revision-tag-select'), + branch = $('#revision-branch-select'), + selects = tag.add(branch), + branch_selected = branch.length > 0 && revision.val() == branch.val(), + tag_selected = tag.length > 0 && revision.val() == tag.val(); - /* - Copy the branch/tag value into the revision box, then disable - the dropdowns before submitting the form - */ - $$('#branch,#tag').each(function(e) { - e.observe('change',function(e) { - $('rev').setValue(e.element().getValue()); - $$('#branch,#tag').invoke('disable'); - e.element().parentNode.submit(); - $$('#branch,#tag').invoke('enable'); + var sendForm = function() { + selects.prop('disable', true); + form.submit(); + selects.prop('disable', false); + } + + /* + If we're viewing a tag or branch, don't display it in the + revision box + */ + if (branch_selected || tag_selected) { + revision.val(''); + } + + /* + Copy the branch/tag value into the revision box, then disable + the dropdowns before submitting the form + */ + selects.on('change', function() { + var select = $(this); + revision.val(select.val()); + sendForm(); + }); + + /* + Disable the branch/tag dropdowns before submitting the revision form + */ + revision.on('keydown', function(e) { + if (e.keyCode == 13) { + sendForm(); + } }); }); +}(jQuery)); - /* - Disable the branch/tag dropdowns before submitting the revision form - */ - $('rev').observe('keydown', function(e) { - if (e.keyCode == 13) { - $$('#branch,#tag').invoke('disable'); - e.element().parentNode.submit(); - $$('#branch,#tag').invoke('enable'); - } - }); -}); diff --git a/app/assets/stylesheets/_misc_legacy.sass b/app/assets/stylesheets/_misc_legacy.sass index 93e72e4ff3a..512a3ab391b 100644 --- a/app/assets/stylesheets/_misc_legacy.sass +++ b/app/assets/stylesheets/_misc_legacy.sass @@ -507,11 +507,6 @@ div.issue hr tr.time-entry white-space: normal -/* comments */ - -.wiki ol li - list-style: decimal inside - /* scm */ #content table .changeset td.id a:hover diff --git a/app/assets/stylesheets/content/_attributes_group.sass b/app/assets/stylesheets/content/_attributes_group.sass index c08feab4b3f..2023a4692c1 100644 --- a/app/assets/stylesheets/content/_attributes_group.sass +++ b/app/assets/stylesheets/content/_attributes_group.sass @@ -39,6 +39,13 @@ @include grid-content padding: 0 1rem 0.4rem 0 + // Exclusive toggleable attribute groups + // include a radio input to toggle them, + // but the positioning is off. + .attributes-group.-toggleable & + cursor: pointer + padding-left: 5px + .attributes-group--header-control @include grid-content(shrink) padding: 0 0 0.4rem 0 diff --git a/app/assets/stylesheets/content/_diff.sass b/app/assets/stylesheets/content/_diff.sass index 9687e8e2dce..b08738f0410 100644 --- a/app/assets/stylesheets/content/_diff.sass +++ b/app/assets/stylesheets/content/_diff.sass @@ -45,3 +45,4 @@ ins background-color: #f6f6f6 color: #505050 border: 1px solid #e4e4e4 + line-height: normal diff --git a/app/assets/stylesheets/content/_in_place_editing.sass b/app/assets/stylesheets/content/_in_place_editing.sass index c434bac9e92..0ad629b5276 100644 --- a/app/assets/stylesheets/content/_in_place_editing.sass +++ b/app/assets/stylesheets/content/_in_place_editing.sass @@ -169,6 +169,12 @@ $inplace-edit--selected-date-border-color: $primary-color-dark &.-default font-style: italic + p + font-size: inherit + + &:last-of-type + margin-bottom: 0 + .inplace-edit--preview border: 1px solid $inplace-edit--border-color padding: 0.375rem @@ -216,6 +222,7 @@ a.inplace-editing--trigger-link, .inplace-edit--icon-wrapper visibility: visible + .inplace-editing--container @include grid-block border-color: #fff diff --git a/app/assets/stylesheets/content/_notifications.lsg b/app/assets/stylesheets/content/_notifications.lsg index 6f15174fbff..7904a32658a 100644 --- a/app/assets/stylesheets/content/_notifications.lsg +++ b/app/assets/stylesheets/content/_notifications.lsg @@ -112,3 +112,121 @@ })(jQuery); ``` + +## Upload notifications + +### Upload progress + +``` +
+
+

Uploading files ...

+
+
+
+ 1 of 3 files finished +
+
+
    +
  • + Awesome_Landscape1.png + 20% +
  • +
  • + Awesome_Landscape2.png + 50% +
  • +
  • + Awesome_Landscape3.png + +
  • +
+
+
+
+
+
+``` + +### Upload progress done + +``` +
+
+

Uploading files ...

+
+
+
+ 5 of 5 files finished +
+
+
    +
  • + Awesome_Landscape1.png + +
  • +
  • + Awesome_Landscape2.png + +

    Something went wrong!...

    +
  • +
  • + Awesome_Landscape3.png + +
  • +
  • + Awesome_Landscape3.png + +
  • +
  • + Awesome_Landscape3.png + +
  • +
+
+
+
+
+
+``` + +### Upload progress more then five files simultaneously + +``` +
+
+

Uploading files ...

+
+
+
+ 3 of 5 files finished +
+
+
    +
  • + Awesome_Landscape1.png + 20% +
  • +
  • + Awesome_Landscape2.png + 50% +
  • +
  • + Awesome_Landscape3.png + +
  • +
  • + Awesome_Landscape4.png + +
  • +
  • + Awesome_Landscape5.png + +
  • +
+
+
+
+
+
+``` diff --git a/app/assets/stylesheets/content/_notifications.sass b/app/assets/stylesheets/content/_notifications.sass index 71c02178b01..6e9bd2ee364 100644 --- a/app/assets/stylesheets/content/_notifications.sass +++ b/app/assets/stylesheets/content/_notifications.sass @@ -29,6 +29,8 @@ // NOTE: these are demo ski //nm - notification messages +//pb - progress bar + $nm-font-size: rem-calc(13px) $nm-border-radius: rem-calc(2px) $nm-box-padding: rem-calc(10px 35px 10px 35px) @@ -51,6 +53,17 @@ $nm-color-border: #8fc0db $nm-color-background: #e3f5ff $nm-padding-box: rem-calc(10px) +$nm-pb-border-color: $nm-color-border +$nm-pb-color: $primary-color-dark +$nm-pb-regress-color: whiteSmoke +$nm-pb-border-radius: rem-calc(6) +$nm-pb-height: rem-calc(20) + +$nm-upload-status-font-size: rem-calc(18) +$nm-upload-file-status-icon-size: rem-calc(12) + +$nm-upload-box-padding: rem-calc(15) rem-calc(25) + %nm-icon-error @extend .icon-attention2:before color: $nm-color-error-icon @@ -63,6 +76,17 @@ $nm-padding-box: rem-calc(10px) @extend .icon-attention1:before color: $nm-color-warning-icon +%error-placeholder + background-color: $nm-color-error-background + border-color: $nm-color-error-border + +%success-placeholder + background-color: $nm-color-success-background + border-color: $nm-color-success-border + +%warning-placeholder + background-color: $nm-color-warning-background + border-color: $nm-color-warning-border @mixin messages-icon($top:rem-calc(11), $left:rem-calc(10), $size:rem-calc(18)) @include icon-common @@ -104,30 +128,29 @@ $nm-padding-box: rem-calc(10px) padding: $nm-box-padding &.-error - background-color: $nm-color-error-background - border-color: $nm-color-error-border + @extend %error-placeholder &::before @include messages-icon @extend %nm-icon-error &.-success - background-color: $nm-color-success-background - border-color: $nm-color-success-border + @extend %success-placeholder &::before @include messages-icon @extend %nm-icon-success &.-warning - background-color: $nm-color-warning-background - border-color: $nm-color-warning-border + @extend %warning-placeholder &::before @include messages-icon @extend %nm-icon-warning - &.-severe background-color: $nm-color-error-background border-color: $nm-color-error-border + &.-upload + padding: $nm-upload-box-padding + //@hack adapting to provided styles //messy I dont have all the details for a magic resolve, this might change & p, & ul @@ -135,6 +158,15 @@ $nm-padding-box: rem-calc(10px) & p, & ul>li, & .button font-size: $nm-font-size + +[class^="icon-"] + &.-error + color: $nm-color-error-icon + &.-success + color: $nm-color-success-icon + &.-warning + color: $nm-color-warning-icon + .notification-box--wrapper position: absolute top: rem-calc(52px) //this value probably will change @@ -145,3 +177,72 @@ $nm-padding-box: rem-calc(10px) position: relative .notification-box margin-bottom: rem-calc(3px) + +// awesome animations +.notification-box + &.ng-enter + +transition(opacity 0.5s ease) + opacity: 0 + &.ng-enter.ng-enter-active + opacity: 1 + &.ng-leave + +transition(opacity 0.5s ease) + opacity: 1 + &.ng-leave.ng-leave-active + opacity: 0 + +// upload notification styles +%progress-styles + appearance: none + border-radius: $nm-pb-border-radius + height: $nm-pb-height + animation: animate-stripes 5s linear infinite + background-color: $nm-pb-regress-color + border: rem-calc(1) solid $nm-pb-border-color + color: $nm-pb-color + margin: 0 + +.notification-box--toggle-message + font-size: rem-calc(16) + margin: rem-calc(5 0 15 0) + +.notification-box--uploads + list-style: none + padding: 0 + margin: 0 + +.notification-box--uploads-element + margin: rem-calc(5 0) + .filename + font-weight: bold + [class^="icon-"] + font-size: $nm-upload-file-status-icon-size + +.notification-box--upload-status + font-size: $nm-upload-status-font-size !important + line-height: $nm-upload-status-font-size !important + +.notification-box--content + width: 100% + progress[value] + @extend %progress-styles + &::-moz-progress-bar + border-radius: $nm-pb-border-radius + &::-webkit-progress-bar + background-color: $nm-pb-regress-color + border-radius: $nm-pb-border-radius + &::-webkit-progress-value + border-radius: $nm-pb-border-radius + +.notification-box--notice + border-radius: $nm-border-radius + border-style: solid + border-width: rem-calc(1) + padding: rem-calc(5) + margin: rem-calc(5 0) + &.-error + @extend %error-placeholder + &.-success + @extend %success-placeholder + &.-warning + @extend %warning-placeholder diff --git a/app/assets/stylesheets/content/_wiki.sass b/app/assets/stylesheets/content/_wiki.sass index b36fe202208..24987933658 100644 --- a/app/assets/stylesheets/content/_wiki.sass +++ b/app/assets/stylesheets/content/_wiki.sass @@ -118,9 +118,11 @@ div.wiki ol li list-style-type: decimal + list-style-position: inherit ul li list-style-type: disc + list-style-position: inherit li li margin-left: 1.5em diff --git a/app/assets/stylesheets/content/_work_packages.sass b/app/assets/stylesheets/content/_work_packages.sass index 3f058e1add7..5d827fb21cb 100644 --- a/app/assets/stylesheets/content/_work_packages.sass +++ b/app/assets/stylesheets/content/_work_packages.sass @@ -112,7 +112,7 @@ $work-package-details--tab-height: 40px margin: 0 padding: 0 text-align: center - width: 18.6% + width: 23.25% cursor: pointer a color: #333 diff --git a/app/assets/stylesheets/layout/_work_package.sass b/app/assets/stylesheets/layout/_work_package.sass index 5c8d6a235ff..4226d60ef70 100644 --- a/app/assets/stylesheets/layout/_work_package.sass +++ b/app/assets/stylesheets/layout/_work_package.sass @@ -30,14 +30,32 @@ @extend %absolute-layout-mode #content - $flash-margins-padding: 10px + (2 * 4px) + $flash-margins-padding: 10px // HACK: workaround to ensure correct height applied to child elements + // the dom looks like this + // flash (generated from rails - if it was generated when rendering the page) + // flash (generated from angular - exists always but is hidden with ng-hide if not needed) + // div[ui-view] (main content we want to adjust the height for) + // div style="clear:both;" (is pushed to overflow - of no relevance) + // + // There can be at most two flash messages visible on the page. + // We need to adjust the hight of the div[ui-view] depending on the amount of flash messages. + // + // This makes use of more specific rules overwriting less specific ones. + // Per default, the height is always 100% .flash + div[ui-view] + height: 100% + + // If there is only one flash message shown: + // Subtract the height of the flash message + .flash:not(.ng-hide) ~ div[ui-view] height: calc(100% - #{($content-flash-height + $flash-margins-padding)}) - .flash.ng-hide + div[ui-view] - height: 100% + // If there are two flash messages shown: + // Subtract the height of the two flash messages + .flash:not(.ng-hide) ~ .flash:not(.ng-hide) ~ div[ui-view] + height: calc(100% - #{2 * ($content-flash-height + $flash-margins-padding)}) // HACK: workaround to ensure correct height applied to child elements #work-packages-index @@ -125,6 +143,26 @@ & > .button margin: .5rem .5rem 0 0 +.work-package--attachments--files + > h4 + margin-top: 5px + +.work-package--attachments--drop-box + border: 2px dashed $light-gray + background-color: lighten($gray, 5) + text-align: center + padding: 20px + + > p + margin-bottom: 8px + +.work-package--attachments--label + font-size: 1rem + font-weight: bold + +.work-package--attachments--hint + font-size: 85% + .controller-work_packages #work-packages-query-selection .select2-container @@ -154,3 +192,39 @@ #attributes .form--field-container max-width: 400px + + +%flex-grow-shrink-zero + flex-grow: 0 + flex-shrink: 0 + +.work-package--attachments--files + ul + list-style: none + margin: 0 + li + margin: rem-calc(5 0) + display: flex + flex-direction: row + align-items: center + .filename + margin: rem-calc(0 5) + flex-grow: 1 + flex-basis: auto + overflow: hidden + white-space: nowrap + text-overflow: ellipsis + .filesize + @extend %flex-grow-shrink-zero + margin: rem-calc(0 10) + .button + @extend %flex-grow-shrink-zero + margin: 0 + flex-basis: rem-calc(65) + +//an workaround for ie10 +@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) + //IE10+ specific styles go here + .work-package--attachments--files ul li .filename + width: rem-calc(354) + display: block diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 6f764506192..8f1644ff88a 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -43,8 +43,6 @@ class AdminController < ApplicationController end def projects - @no_configuration_data = Redmine::DefaultData::Loader::no_data? - @status = params[:status] ? params[:status].to_i : 1 c = ARCondition.new(@status == 0 ? 'status <> 0' : ['status = ?', @status]) @@ -62,20 +60,6 @@ class AdminController < ApplicationController @plugins = Redmine::Plugin.all.sort end - # Loads the default configuration - # (roles, types, statuses, workflow, enumerations) - def default_configuration - if request.post? - begin - Redmine::DefaultData::Loader::load(params[:lang]) - flash[:notice] = l(:notice_default_data_loaded) - rescue => e - flash[:error] = l(:error_can_t_load_default_data, ERB::Util.h(e.message)) - end - end - redirect_to action: 'index' - end - def test_email raise_delivery_errors = ActionMailer::Base.raise_delivery_errors # Force ActionMailer to raise delivery errors so we can catch it diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 3df20de5ec4..f9eafd5fc36 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -110,11 +110,6 @@ class ApplicationController < ActionController::Base include Redmine::MenuManager::MenuController helper Redmine::MenuManager::MenuHelper - # TODO: needed? redmine doesn't - Redmine::Scm::Base.all.each do |scm| - require "repository/#{scm.underscore}" - end - def default_url_options(_options = {}) { layout: params['layout'] } end @@ -219,6 +214,13 @@ class ApplicationController < ActionController::Base I18n.fallbacks.defaults = fallbacks end + ## + # Sets the language for the current request. + # The language is determined with the following priority: + # + # 1. The language as configured by the user. + # 2. The first language defined in the Accept-Language header sent by the browser. + # 3. OpenProject's default language defined in the settings. def set_localization lang = nil lang = find_language(User.current.language) if User.current.logged? diff --git a/app/controllers/journals_controller.rb b/app/controllers/journals_controller.rb index f90be231c3e..af135440c26 100644 --- a/app/controllers/journals_controller.rb +++ b/app/controllers/journals_controller.rb @@ -90,13 +90,15 @@ class JournalsController < ApplicationController end def diff + journal = Journal::AggregatedJournal.for_journal(@journal) + if valid_diff? field = params[:field].parameterize.underscore.to_sym - from = @journal.changed_data[field][0] - to = @journal.changed_data[field][1] + from = journal.details[field][0] + to = journal.details[field][1] @diff = Redmine::Helpers::Diff.new(to, from) - @journable = @journal.journable + @journable = journal.journable respond_to do |format| format.html format.js do diff --git a/app/controllers/repositories_controller.rb b/app/controllers/repositories_controller.rb index 99b5bfe9cbc..acc0a69e8ae 100644 --- a/app/controllers/repositories_controller.rb +++ b/app/controllers/repositories_controller.rb @@ -30,45 +30,61 @@ require 'SVG/Graph/Bar' require 'SVG/Graph/BarHorizontal' require 'digest/sha1' +require_dependency 'open_project/scm/adapters' -class ChangesetNotFound < Exception; end -class InvalidRevisionParam < Exception; end +class ChangesetNotFound < Exception +end +class InvalidRevisionParam < Exception +end class RepositoriesController < ApplicationController include PaginationHelper menu_item :repository - menu_item :settings, only: :edit + menu_item :settings, only: [:edit, :destroy_info] default_search_scope :changesets - before_filter :find_repository, except: :edit - before_filter :find_project, only: :edit + before_filter :find_project_by_project_id + before_filter :find_repository, except: [:edit, :update, :create, :destroy, :destroy_info] before_filter :authorize accept_key_auth :revisions - rescue_from Redmine::Scm::Adapters::CommandFailed, with: :show_error_command_failed + rescue_from OpenProject::Scm::Exceptions::ScmError, with: :show_error_command_failed def edit - @repository = @project.repository - if !@repository - @repository = Repository.factory(params[:repository_scm]) - @repository.project = @project if @repository + service = Scm::RepositoryFactoryService.new(@project, params) + if service.build_temporary + @repository = service.repository + else + logger.error("Cannot create repository for #{params[:scm_vendor]}") + flash.now[:error] = service.build_error end - if request.post? && @repository - @repository.attributes = params[:repository] - @repository.save - end - - menu_reload_required = if @repository.persisted? && !@project.repository - @project.reload # needed to reload association - end respond_to do |format| - format.js do - render template: '/projects/settings/repository', - locals: { project: @project, - reload_menu: menu_reload_required } - end + format.js { render 'repositories/settings/repository_form' } + end + end + + def update + @repository = @project.repository + update_repository(params.fetch(:repository, {})) + respond_to do |format| + format.js { render 'repositories/settings/repository_form' } + end + end + + def create + service = Scm::RepositoryFactoryService.new(@project, params) + if service.build_and_save + @repository = service.repository + flash[:notice] = l('repositories.create_successful') + flash[:notice] << (' ' + l('repositories.create_managed_delay')) if @repository.managed? + else + flash[:error] = service.build_error + end + + respond_to do |format| + format.js { render js: "window.location = '#{settings_repository_tab_path}'" } end end @@ -87,9 +103,15 @@ class RepositoriesController < ApplicationController end end + def destroy_info + @repository = @project.repository + @back_link = settings_repository_tab_path + end + def destroy - @repository.destroy - redirect_to controller: '/projects', action: 'settings', id: @project, tab: 'repository' + @project.repository.destroy + flash[:notice] = I18n.t('repositories.delete_sucessful') + redirect_to settings_repository_tab_path end def show @@ -123,7 +145,8 @@ class RepositoriesController < ApplicationController end def revisions - @changesets = @repository.changesets.includes(:user, :repository) + @changesets = @repository.changesets + .includes(:user, :repository) .page(params[:page]) .per_page(per_page_param) @@ -138,13 +161,17 @@ class RepositoriesController < ApplicationController (show_error_not_found; return) unless @entry # If the entry is a dir, show the browser - (show; return) if @entry.is_dir? + if @entry.dir? + show + return + end @content = @repository.cat(@path, @rev) (show_error_not_found; return) unless @content if 'raw' == params[:format] || (@content.size && @content.size > Setting.file_max_size_displayed.to_i.kilobyte) || !is_entry_text_data?(@content, @path) + # Force the download send_opt = { filename: filename_for_content_disposition(@path.split('/').last) } send_type = Redmine::MimeType.of(@path) @@ -174,6 +201,7 @@ class RepositoriesController < ApplicationController end true end + private :is_entry_text_data? def annotate @@ -204,9 +232,10 @@ class RepositoriesController < ApplicationController (show_error_not_found; return) unless @diff filename = "changeset_r#{@rev}" filename << "_r#{@rev_to}" if @rev_to - send_data @diff.join, filename: "#{filename}.diff", - type: 'text/x-patch', - disposition: 'attachment' + send_data @diff.join, + filename: "#{filename}.diff", + type: 'text/x-patch', + disposition: 'attachment' else @diff_type = params[:type] || User.current.pref[:diff_type] || 'inline' @diff_type = 'inline' unless %w(inline sbs).include?(@diff_type) @@ -245,6 +274,7 @@ class RepositoriesController < ApplicationController end data = graph_commits_per_author(@repository) end + if data headers['Content-Type'] = 'image/svg+xml' send_data(data, type: 'image/svg+xml', disposition: 'inline') @@ -257,10 +287,26 @@ class RepositoriesController < ApplicationController REV_PARAM_RE = %r{\A[a-f0-9]*\Z}i + def update_repository(repo_params) + @repository.attributes = @repository.class.permitted_params(repo_params) + + if @repository.save + flash.now[:notice] = l('repositories.update_settings_successful') + else + flash.now[:error] = @repository.errors.full_messages.join('\n') + end + end + + def settings_repository_tab_path + settings_project_path(@project, tab: 'repository') + end + def find_repository - @project = Project.find(params[:project_id]) @repository = @project.repository (render_404; return false) unless @repository + + @repository.scm.check_availability! + @path = params[:path] || '' @rev = params[:rev].blank? ? @repository.default_branch : params[:rev].to_s.strip @rev_to = params[:rev_to] @@ -270,6 +316,8 @@ class RepositoriesController < ApplicationController raise InvalidRevisionParam end end + rescue OpenProject::Scm::Exceptions::ScmEmpty + render 'empty' rescue ActiveRecord::RecordNotFound render_404 rescue InvalidRevisionParam @@ -280,7 +328,6 @@ class RepositoriesController < ApplicationController render_error message: l(:error_scm_not_found), status: 404 end - # Handler for Redmine::Scm::Adapters::CommandFailed exception def show_error_command_failed(exception) render_error l(:error_scm_command_failed, exception.message) end diff --git a/app/controllers/sys_controller.rb b/app/controllers/sys_controller.rb index 585254af04f..efed1875e28 100644 --- a/app/controllers/sys_controller.rb +++ b/app/controllers/sys_controller.rb @@ -32,6 +32,8 @@ require 'open_project/repository_authentication' class SysController < ActionController::Base before_filter :check_enabled before_filter :require_basic_auth, only: [:repo_auth] + before_filter :find_project, only: [:update_required_storage] + before_filter :find_repository_with_storage, only: [:update_required_storage] def projects p = Project.active.has_module(:repository) @@ -54,8 +56,10 @@ class SysController < ActionController::Base render nothing: true, status: 409 else logger.info "Repository for #{project.name} was reported to be created by #{request.remote_ip}." - project.repository = Repository.factory(params[:vendor], params[:repository]) - if project.repository && project.repository.save + service = Scm::RepositoryFactoryService.new(project, params) + + if service.build_and_save + project.repository = service.repository render xml: project.repository, status: 201 else render nothing: true, status: 422 @@ -63,6 +67,11 @@ class SysController < ActionController::Base end end + def update_required_storage + update_storage_information(@repository, params[:force] == '1') + render nothing: true, status: 200 + end + def fetch_changesets projects = [] if params[:id] @@ -106,6 +115,33 @@ class SysController < ActionController::Base private + def update_storage_information(repository, force = false) + if force + Delayed::Job.enqueue ::Scm::StorageUpdaterJob.new(repository) + else + repository.required_disk_storage + end + end + + def find_project + @project = Project.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render text: "Could not find project ##{params[:id]}.", status: 404 + end + + def find_repository_with_storage + @repository = @project.repository + + if @repository.nil? + render text: "Project ##{@project.id} does not have a repository.", status: 404 + else + return true if @repository.scm.storage_available? + render text: 'repositories.storage.not_available', status: 400 + end + + false + end + def require_basic_auth authenticate_with_http_basic do |username, password| @authenticated_user = cached_user_login(username, password) diff --git a/app/controllers/work_packages/bulk_controller.rb b/app/controllers/work_packages/bulk_controller.rb index fa49fa2dee8..017509fb350 100644 --- a/app/controllers/work_packages/bulk_controller.rb +++ b/app/controllers/work_packages/bulk_controller.rb @@ -61,7 +61,7 @@ class WorkPackages::BulkController < ApplicationController work_package.assign_attributes attributes call_hook(:controller_work_package_bulk_before_save, params: params, work_package: work_package) - JournalObserver.instance.send_notification = params[:send_notification] == '0' ? false : true + JournalManager.send_notification = params[:send_notification] == '0' ? false : true unless work_package.save unsaved_work_package_ids << work_package.id end diff --git a/app/controllers/work_packages/moves_controller.rb b/app/controllers/work_packages/moves_controller.rb index 54d868d4f8f..e0be320ecb9 100644 --- a/app/controllers/work_packages/moves_controller.rb +++ b/app/controllers/work_packages/moves_controller.rb @@ -60,9 +60,10 @@ class WorkPackages::MovesController < ApplicationController :new_project_id, ids: []) - if r = work_package.move_to_project(@target_project, new_type, copy: @copy, - attributes: permitted_params, - journal_note: @notes) + move_service = MoveWorkPackageService.new(work_package, current_user) + if r = move_service.call(@target_project, new_type, copy: @copy, + attributes: permitted_params, + journal_note: @notes) moved_work_packages << r else unsaved_work_package_ids << work_package.id @@ -79,7 +80,7 @@ class WorkPackages::MovesController < ApplicationController else redirect_to project_work_packages_path(@project) end - end + end def set_flash_from_bulk_work_package_save(work_packages, unsaved_work_package_ids) if unsaved_work_package_ids.empty? and not work_packages.empty? @@ -101,7 +102,7 @@ class WorkPackages::MovesController < ApplicationController def prepare_for_work_package_move @work_packages.sort! @copy = params.has_key? :copy - @allowed_projects = WorkPackage.allowed_target_projects_on_move + @allowed_projects = WorkPackage.allowed_target_projects_on_move(current_user) @target_project = @allowed_projects.detect { |p| p.id.to_s == params[:new_project_id].to_s } if params[:new_project_id] @target_project ||= @project @types = @target_project.types diff --git a/app/controllers/work_packages_controller.rb b/app/controllers/work_packages_controller.rb index 629ac491a8d..664df813b9c 100644 --- a/app/controllers/work_packages_controller.rb +++ b/app/controllers/work_packages_controller.rb @@ -131,7 +131,7 @@ class WorkPackagesController < ApplicationController def create call_hook(:controller_work_package_new_before_save, params: params, work_package: work_package) - WorkPackageObserver.instance.send_notification = send_notifications? + JournalManager.send_notification = send_notifications? work_package.attach_files(params[:attachments]) diff --git a/app/helpers/planning_elements_helper.rb b/app/helpers/planning_elements_helper.rb index ca171486bae..2c9886a8529 100644 --- a/app/helpers/planning_elements_helper.rb +++ b/app/helpers/planning_elements_helper.rb @@ -98,6 +98,6 @@ module PlanningElementsHelper formatter.send(:format_details, attribute, - journal.changed_data[attribute]) + journal.details[attribute]) end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 0bbe24b3661..e5791d7794d 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -41,7 +41,7 @@ module ProjectsHelper { name: 'members', action: :manage_members, partial: 'projects/settings/members', label: :label_member_plural }, { name: 'versions', action: :manage_versions, partial: 'projects/settings/versions', label: :label_version_plural }, { name: 'categories', action: :manage_categories, partial: 'projects/settings/categories', label: :label_work_package_category_plural }, - { name: 'repository', action: :manage_repository, partial: 'projects/settings/repository', label: :label_repository }, + { name: 'repository', action: :manage_repository, partial: 'repositories/settings', label: :label_repository }, { name: 'boards', action: :manage_boards, partial: 'projects/settings/boards', label: :label_board_plural }, { name: 'activities', action: :manage_project_activities, partial: 'projects/settings/activities', label: :enumeration_activities }, { name: 'types', action: :manage_types, partial: 'projects/settings/types', label: :label_type_plural } diff --git a/app/helpers/repositories_helper.rb b/app/helpers/repositories_helper.rb index 34b655b5dc1..59d07c9d3d0 100644 --- a/app/helpers/repositories_helper.rb +++ b/app/helpers/repositories_helper.rb @@ -58,7 +58,8 @@ module RepositoriesHelper when 'A' # Detects moved/copied files if !change.from_path.blank? - change.action = @changeset.changes.detect { |c| c.action == 'D' && c.path == change.from_path } ? 'R' : 'C' + action = @changeset.changes.detect { |c| c.action == 'D' && c.path == change.from_path } + change.action = action ? 'R' : 'C' end change when 'D' @@ -97,27 +98,30 @@ module RepositoriesHelper if s = tree[file][:s] style << ' folder' path_param = without_leading_slash(to_path_param(@repository.relative_path(file))) - text = link_to(h(text), controller: '/repositories', - action: 'show', - project_id: @project, - path: path_param, - rev: @changeset.identifier) + text = link_to(h(text), + controller: '/repositories', + action: 'show', + project_id: @project, + path: path_param, + rev: @changeset.identifier) output << "
  • #{text}
  • " output << render_changes_tree(s) elsif c = tree[file][:c] style << " change-#{c.action}" path_param = without_leading_slash(to_path_param(@repository.relative_path(c.path))) - text = link_to(h(text), controller: '/repositories', - action: 'entry', - project_id: @project, - path: path_param, - rev: @changeset.identifier) unless c.action == 'D' + text = link_to(h(text), + controller: '/repositories', + action: 'entry', + project_id: @project, + path: path_param, + rev: @changeset.identifier) unless c.action == 'D' text << raw(" - #{h(c.revision)}") unless c.revision.blank? - text << raw(' (' + link_to(l(:label_diff), controller: '/repositories', - action: 'diff', - project_id: @project, - path: path_param, - rev: @changeset.identifier) + ') ') if c.action == 'M' + text << raw(' (' + link_to(l(:label_diff), + controller: '/repositories', + action: 'diff', + project_id: @project, + path: path_param, + rev: @changeset.identifier) + ') ') if c.action == 'M' text << raw(' ' + content_tag('span', h(c.from_path), class: 'copied-from')) unless c.from_path.blank? output << "
  • #{text}
  • " end @@ -155,6 +159,7 @@ module RepositoriesHelper end str = replace_invalid_utf8(str) end + private :to_utf8_internal def replace_invalid_utf8(str) @@ -162,8 +167,8 @@ module RepositoriesHelper if str.respond_to?(:force_encoding) str.force_encoding('UTF-8') if !str.valid_encoding? - str = str.encode('US-ASCII', invalid: :replace, - undef: :replace, replace: '?').encode('UTF-8') + str = str.encode("US-ASCII", invalid: :replace, + undef: :replace, replace: '?').encode("UTF-8") end else # removes invalid UTF8 sequences @@ -175,36 +180,66 @@ module RepositoriesHelper str end - def repository_field_tags(form, repository) - method = repository.class.name.demodulize.underscore + '_field_tags' - if repository.is_a?(Repository) && - respond_to?(method) && method != 'repository_field_tags' - send(method, form, repository) + ## + # Retrieves all valid SCM vendors from the Manager + # and injects an already persisted repository for correctly + # displaying an existing repository. + def scm_options(repository = nil) + scms = OpenProject::Scm::Manager.enabled + vendor = repository.nil? ? nil : repository.vendor + + ## Set selected vendor + if vendor && !repository.new_record? + scms[vendor] = vendor end + + # Remove repositories that were configured to have no + # available types left. + scms.reject! { |_, klass| klass.available_types.empty? } + + scms = [default_selected_option] + scms.keys + options_for_select(scms, vendor) end - def scm_select_tag(repository) - scm_options = [["--- #{l(:actionview_instancetag_blank_option)} ---", '']] - Redmine::Scm::Base.configured.each do |scm| - if Setting.enabled_scm.include?(scm) || - (repository && repository.class.name.demodulize == scm) - scm_options << ["Repository::#{scm}".constantize.scm_name, scm] - end - end - select_tag('repository_scm', - options_for_select(scm_options, repository.class.name.demodulize), - disabled: (repository && !repository.new_record?), - onchange: remote_function( - url: { - controller: '/repositories', - action: 'edit', - id: @project - }, - method: :get, - with: 'Form.serialize(this.form)') + def default_selected_option + [ + "--- #{l(:actionview_instancetag_blank_option)} ---", + '', + { disabled: true, selected: true } + ] + end + + def vendor_name(repository) + repository.vendor.underscore + end + + def scm_vendor_tag(repository) + select_tag('scm_vendor', + scm_options(repository), + class: 'form--select repositories--remote-select', + data: { + remote: true, + url: url_for(controller: '/repositories', + action: 'edit', project_id: @project.id), + }, + disabled: (repository && !repository.new_record?) ) end + def git_path_encoding_options(repository) + default = repository.new_record? ? 'UTF-8' : repository.path_encoding + options_for_select(Setting::ENCODINGS, default) + end + + ## + # Determines whether the repository settings save button should be shown. + # By default, it is not shown when repository exists and is managed. + def show_settings_save_button?(repository) + @repository.nil? || + @repository.new_record? || + !@repository.managed? + end + def with_leading_slash(path) path.to_s.starts_with?('/') ? path : "/#{path}" end @@ -212,74 +247,4 @@ module RepositoriesHelper def without_leading_slash(path) path.gsub(%r{\A/+}, '') end - - def subversion_field_tags(form, repository) - url = content_tag('div', class: 'form--field') { - form.text_field(:url, - size: 60, - required: true, - disabled: (repository && !repository.root_url.blank?)) + - content_tag('div', - 'file:///, http://, https://, svn://, svn+[tunnelscheme]://', - class: 'form--field-instructions') - } - - login = content_tag('div', class: 'form--field') { - form.text_field(:login, size: 30) - } - - pwd = content_tag('div', class: 'form--field') { - form.password_field(:password, - size: 30, - name: 'ignore', - value: ((repository.new_record? || repository.password.blank?) ? '' : ('x' * 15)), - onfocus: "this.value=''; this.name='repository[password]';", - onchange: "this.name='repository[password]';") - } - - url + login + pwd - end - - def git_field_tags(form, repository) - url = content_tag('div', class: 'form--field -required') { - form.text_field(:url, - label: :label_git_path, - size: 60, - disabled: (repository && !repository.root_url.blank?)) + - content_tag('div', - l(:text_git_repo_example), - class: 'form--field-instructions') - } - - encoding = content_tag('div', class: 'form--field') { - form.select(:path_encoding, - [nil] + Setting::ENCODINGS, - label: l(:label_path_encoding)) + - content_tag('div', - l(:text_default_encoding), - class: 'form--field-instructions') - } - - url + encoding - end - - def filesystem_field_tags(form, repository) - url = content_tag('div', class: 'form--field -required') { - form.text_field(:url, - label: :label_filesystem_path, - size: 60, - disabled: (repository && !repository.root_url.blank?)) - } - - encoding = content_tag('div', class: 'form--field') { - form.select(:path_encoding, - [nil] + Setting::ENCODINGS, - label: l(:label_path_encoding)) + - content_tag('div', - l(:text_default_encoding), - class: 'form--field-instructions') - } - - url + encoding - end end diff --git a/app/helpers/work_packages_helper.rb b/app/helpers/work_packages_helper.rb index b3a48b53321..1401d906481 100644 --- a/app/helpers/work_packages_helper.rb +++ b/app/helpers/work_packages_helper.rb @@ -173,9 +173,9 @@ module WorkPackagesHelper ['start_date', 'due_date'].each do |date| if changed_dates[date].nil? && - journal.changed_data[date] && - journal.changed_data[date].first - changed_dates[date] = " (#{journal.changed_data[date].first})".html_safe + journal.details[date] && + journal.details[date].first + changed_dates[date] = " (#{journal.details[date].first})".html_safe end end end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 60d1b215089..96a5dff3820 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -47,8 +47,10 @@ class UserMailer < BaseMailer end end - def work_package_added(user, work_package, author) + def work_package_added(user, journal, author) + work_package = journal.journable.reload @issue = work_package # instance variable is used in the view + @journal = journal set_work_package_headers(work_package) diff --git a/app/models/activity/work_package_activity_provider.rb b/app/models/activity/work_package_activity_provider.rb index 73453a56921..3d0c283681a 100644 --- a/app/models/activity/work_package_activity_provider.rb +++ b/app/models/activity/work_package_activity_provider.rb @@ -65,7 +65,7 @@ class Activity::WorkPackageActivityProvider < Activity::BaseActivityProvider state = '' journal = Journal.find(event['event_id']) - if journal.changed_data.empty? && !journal.initial? + if journal.details.empty? && !journal.initial? state = '-note' else state = ActiveRecord::ConnectionAdapters::Column.value_to_boolean(event['status_closed']) ? '-closed' : '-edit' diff --git a/app/models/auth_source.rb b/app/models/auth_source.rb index 195fd7b0f17..cdaab434fa0 100644 --- a/app/models/auth_source.rb +++ b/app/models/auth_source.rb @@ -40,7 +40,9 @@ class AuthSource < ActiveRecord::Base def authenticate(_login, _password) end + # implemented by a subclass, should raise when no connection is possible and not raise on success def test_connection + raise I18n.t('auth_source.using_abstract_auth_source') end def auth_method_name diff --git a/app/models/comment_observer.rb b/app/models/comment_observer.rb index 8319457331a..09254f297de 100644 --- a/app/models/comment_observer.rb +++ b/app/models/comment_observer.rb @@ -29,13 +29,12 @@ class CommentObserver < ActiveRecord::Observer def after_create(comment) - return unless Notifier.notify?(:news_comment_added) + return unless Setting.notified_events.include?('news_comment_added') if comment.commented.is_a?(News) news = comment.commented recipients = news.recipients + news.watcher_recipients - users = User.find_all_by_mails(recipients) - users.each do |user| + recipients.uniq.each do |user| UserMailer.news_comment_added(user, comment, User.current).deliver end end diff --git a/app/models/journal.rb b/app/models/journal.rb index 3cfd9037608..10e690491cb 100644 --- a/app/models/journal.rb +++ b/app/models/journal.rb @@ -30,6 +30,7 @@ class Journal < ActiveRecord::Base self.table_name = 'journals' + include JournalChanges include JournalFormatter include FormatHooks @@ -99,6 +100,7 @@ class Journal < ActiveRecord::Base get_changes end + # TODO Evaluate whether this can be removed without disturbing any migrations alias_method :changed_data, :details def new_value_for(prop) @@ -134,57 +136,6 @@ class Journal < ActiveRecord::Base end end - def get_changes - return {} if data.nil? - - if @changes.nil? - @changes = HashWithIndifferentAccess.new - - if predecessor.nil? - @changes = data.journaled_attributes.select { |_, v| !v.nil? } - .inject({}) { |h, (k, v)| h[k] = [nil, v]; h } - else - normalized_data = JournalManager.normalize_newlines(data.journaled_attributes) - normalized_predecessor_data = JournalManager.normalize_newlines(predecessor.data.journaled_attributes) - - normalized_data.select { |k, v| - # we dont record changes for changes from nil to empty strings and vice versa - pred = normalized_predecessor_data[k] - v != pred && (v.present? || pred.present?) - }.each do |k, v| - @changes[k] = [normalized_predecessor_data[k], v] - end - end - - @changes.merge!(get_association_changes predecessor, 'attachable', 'attachments', :attachment_id, :filename) - @changes.merge!(get_association_changes predecessor, 'customizable', 'custom_fields', :custom_field_id, :value) - end - - @changes - end - - def get_association_changes(predecessor, journal_association, association, key, value) - changes = {} - journal_assoc_name = "#{journal_association}_journals" - - if predecessor.nil? - send(journal_assoc_name).each_with_object(changes) { |a, h| h["#{association}_#{a.send(key)}"] = [nil, a.send(value)] } - else - current = send(journal_assoc_name).map(&:attributes) - predecessor_attachable_journals = predecessor.send(journal_assoc_name).map(&:attributes) - - merged_journals = JournalManager.merge_reference_journals_by_id current, - predecessor_attachable_journals, - key.to_s - - changes.merge! JournalManager.added_references(merged_journals, association, value.to_s) - changes.merge! JournalManager.removed_references(merged_journals, association, value.to_s) - changes.merge! JournalManager.changed_references(merged_journals, association, value.to_s) - end - - changes - end - def predecessor @predecessor ||= self.class .where(journable_type: journable_type, journable_id: journable_id) diff --git a/app/models/journal/aggregated_journal.rb b/app/models/journal/aggregated_journal.rb index 4e529254afd..7ab0349e0a3 100644 --- a/app/models/journal/aggregated_journal.rb +++ b/app/models/journal/aggregated_journal.rb @@ -41,13 +41,35 @@ # be dropped class Journal::AggregatedJournal class << self - def aggregated_journals(journable: nil) - query_aggregated_journals(journable: journable).map { |journal| + # Returns the aggregated journal that contains the specified (vanilla/pure) journal. + def for_journal(pure_journal) + raw = Journal::AggregatedJournal.query_aggregated_journals(journable: pure_journal.journable) + .where("#{version_projection} >= ?", pure_journal.version) + .first + + raw ? Journal::AggregatedJournal.new(raw) : nil + end + + def with_notes_id(notes_id) + raw_journal = query_aggregated_journals + .where("#{table_name}.id = ?", notes_id) + .first + + raw_journal ? Journal::AggregatedJournal.new(raw_journal) : nil + end + + ## + # The +journable+ parameter allows to filter for aggregated journals of a given journable. + # + # The +until_version+ parameter can be used in conjunction with the +journable+ parameter + # to see the aggregated journals as if no versions were known after the specified version. + def aggregated_journals(journable: nil, until_version: nil) + query_aggregated_journals(journable: journable, until_version: until_version).map { |journal| Journal::AggregatedJournal.new(journal) } end - def query_aggregated_journals(journable: nil) + def query_aggregated_journals(journable: nil, until_version: nil) # Using the roughly aggregated groups from :sql_rough_group we need to merge journals # where an entry with empty notes follows an entry containing notes, so that the notes # from the main entry are taken, while the remaining information is taken from the @@ -60,21 +82,58 @@ class Journal::AggregatedJournal # that our own row (master) would not already have been merged by its predecessor. If it is # (that means if we can find a valid predecessor), we drop our current row, because it will # already be present (in a merged form) in the row of our predecessor. - Journal.from("(#{sql_rough_group(journable, 1)}) #{table_name}") - .joins("LEFT OUTER JOIN (#{sql_rough_group(journable, 2)}) addition + Journal.from("(#{sql_rough_group(journable, until_version, 1)}) #{table_name}") + .joins("LEFT OUTER JOIN (#{sql_rough_group(journable, until_version, 2)}) addition ON #{sql_on_groups_belong_condition(table_name, 'addition')}") - .joins("LEFT OUTER JOIN (#{sql_rough_group(journable, 3)}) predecessor + .joins("LEFT OUTER JOIN (#{sql_rough_group(journable, until_version, 3)}) predecessor ON #{sql_on_groups_belong_condition('predecessor', table_name)}") .where('predecessor.id IS NULL') + .order("COALESCE(addition.created_at, #{table_name}.created_at) ASC") .select("#{table_name}.journable_id, #{table_name}.journable_type, #{table_name}.user_id, #{table_name}.notes, #{table_name}.id \"notes_id\", + #{table_name}.version \"notes_version\", #{table_name}.activity_type, COALESCE(addition.created_at, #{table_name}.created_at) \"created_at\", COALESCE(addition.id, #{table_name}.id) \"id\", - COALESCE(addition.version, #{table_name}.version) \"version\"") + #{version_projection} \"version\"") + end + + # Returns whether "notification-hiding" should be assumed for the given journal pair. + # This leads to an aggregated journal effectively blocking notifications of an earlier journal, + # because it "steals" the addition from its predecessor. See the specs section under + # "mail suppressing aggregation" (for EnqueueWorkPackageNotificationJob) for more details + def hides_notifications?(successor, predecessor) + return false unless successor && predecessor + + timeout = Setting.journal_aggregation_time_minutes.to_i.minutes + + if successor.journable_type != predecessor.journable_type || + successor.journable_id != predecessor.journable_id || + successor.user_id != predecessor.user_id || + (successor.created_at - predecessor.created_at) <= timeout + return false + end + + # imaginary state in which the successor never existed + # if this makes the predecessor disappear, the successor must have taken journals + # from it (that now became part of the predecessor again). + !Journal::AggregatedJournal + .query_aggregated_journals( + journable: successor.journable, + until_version: successor.version - 1) + .where("#{version_projection} = #{predecessor.version}") + .exists? + end + + def table_name + Journal.table_name + end + + def version_projection + "COALESCE(addition.version, #{table_name}.version)" end private @@ -88,21 +147,31 @@ class Journal::AggregatedJournal # To be able to self-join results of this statement, we add an additional column called # "group_number" to the result. This allows to compare a group resulting from this query with # its predecessor and successor. - def sql_rough_group(journable, uid) + def sql_rough_group(journable, until_version, uid) + if until_version && !journable + raise 'need to provide a journable, when specifying a version limit' + end + sql = "SELECT predecessor.*, #{sql_group_counter(uid)} AS group_number FROM #{sql_rough_group_from_clause(uid)} LEFT OUTER JOIN journals successor ON predecessor.version + 1 = successor.version AND predecessor.journable_type = successor.journable_type AND predecessor.journable_id = successor.journable_id + #{until_version ? " AND successor.version <= #{until_version}" : ''} WHERE (predecessor.user_id != successor.user_id OR (predecessor.notes != '' AND predecessor.notes IS NOT NULL) OR #{sql_beyond_aggregation_time?('predecessor', 'successor')} OR successor.id IS NULL)" if journable + raise 'journable has no id' if journable.id.nil? sql += " AND predecessor.journable_type = '#{journable.class.name}' AND predecessor.journable_id = #{journable.id}" + + if until_version + sql += " AND predecessor.version <= #{until_version}" + end end sql @@ -117,7 +186,7 @@ class Journal::AggregatedJournal group_counter = mysql_group_count_variable(uid) "(#{group_counter} := #{group_counter} + 1)" else - 'row_number() OVER ()' + 'row_number() OVER (ORDER BY predecessor.version ASC)' end end @@ -153,7 +222,12 @@ class Journal::AggregatedJournal # to be considered for aggregation. This takes the current instance settings for temporal # proximity into account. def sql_beyond_aggregation_time?(predecessor, successor) - aggregation_time_seconds = Setting.journal_aggregation_time_minutes.to_i * 60 + aggregation_time_seconds = Setting.journal_aggregation_time_minutes.to_i.minutes + if aggregation_time_seconds == 0 + # if aggregation is disabled, we consider everything to be beyond aggregation time + # even if creation dates are exactly equal + return '(true = true)' + end if OpenProject::Database.mysql? difference = "TIMESTAMPDIFF(second, #{predecessor}.created_at, #{successor}.created_at)" @@ -165,29 +239,44 @@ class Journal::AggregatedJournal "(#{difference} > #{threshold})" end - - def table_name - Journal.table_name - end end + include JournalChanges + include JournalFormatter + include Redmine::Acts::Journalized::FormatHooks + + register_journal_formatter :diff, OpenProject::JournalFormatter::Diff + register_journal_formatter :attachment, OpenProject::JournalFormatter::Attachment + register_journal_formatter :custom_field, OpenProject::JournalFormatter::CustomField + + alias_method :details, :get_changes + delegate :journable_type, :journable_id, :journable, :user_id, :user, :notes, + :notes?, :activity_type, :created_at, :id, :version, :attributes, + :attachable_journals, + :customizable_journals, + :editable_by?, to: :journal def initialize(journal) @journal = journal end + # returns an instance of this class that is reloaded from the database + def reloaded + self.class.with_notes_id(notes_id) + end + def user @user ||= User.find(user_id) end @@ -195,8 +284,9 @@ class Journal::AggregatedJournal def predecessor unless defined? @predecessor raw_journal = self.class.query_aggregated_journals(journable: journable) - .where("#{Journal.table_name}.version < ?", version) - .order("#{Journal.table_name}.version DESC") + .where("#{self.class.version_projection} < ?", version) + .except(:order) + .order("#{self.class.version_projection} DESC") .first @predecessor = raw_journal ? Journal::AggregatedJournal.new(raw_journal) : nil @@ -205,10 +295,28 @@ class Journal::AggregatedJournal @predecessor end + def successor + unless defined? @successor + raw_journal = self.class.query_aggregated_journals(journable: journable) + .where("#{self.class.version_projection} > ?", version) + .except(:order) + .order("#{self.class.version_projection} ASC") + .first + + @successor = raw_journal ? Journal::AggregatedJournal.new(raw_journal) : nil + end + + @successor + end + def initial? predecessor.nil? end + def data + @data ||= "Journal::#{journable_type}Journal".constantize.find_by_journal_id(id) + end + # ARs automagic addition of dynamic columns (those not present in the physical table) seems # not to work with PostgreSQL and simply return a string for unknown columns. # Thus we need to ensure manually that this column is correctly casted. @@ -216,6 +324,10 @@ class Journal::AggregatedJournal ActiveRecord::ConnectionAdapters::Column.value_to_integer(journal.notes_id) end + def notes_version + ActiveRecord::ConnectionAdapters::Column.value_to_integer(journal.notes_version) + end + private attr_reader :journal diff --git a/app/models/journal_manager.rb b/app/models/journal_manager.rb index 2fbccdfc9a4..ca9c372e5dc 100644 --- a/app/models/journal_manager.rb +++ b/app/models/journal_manager.rb @@ -28,6 +28,12 @@ #++ class JournalManager + class << self + attr_accessor :send_notification + end + + self.send_notification = true + def self.is_journalized?(obj) not obj.nil? and obj.respond_to? :journals end @@ -85,31 +91,45 @@ class JournalManager # This would lead to false change information, otherwise. # We need to be careful though, because we want to accept false (and false.blank? == true) def self.remove_empty_associations(associations, value) - associations.reject { |h| h.has_key?(value) && h[value].blank? && h[value] != false } + associations.reject { |association| + association.has_key?(value) && + association[value].blank? && + association[value] != false + } end - def self.merge_reference_journals_by_id(current, predecessor, key) - all_attachable_journal_ids = current.map { |j| j[key] } | predecessor.map { |j| j[key] } + def self.merge_reference_journals_by_id(new_journals, old_journals, id_key) + all_associated_journal_ids = new_journals.map { |j| j[id_key] } | + old_journals.map { |j| j[id_key] } - all_attachable_journal_ids.each_with_object({}) { |i, h| - h[i] = [predecessor.detect { |j| j[key] == i }, - current.detect { |j| j[key] == i }] + all_associated_journal_ids.each_with_object({}) { |id, result| + result[id] = [old_journals.detect { |j| j[id_key] == id }, + new_journals.detect { |j| j[id_key] == id }] } end def self.added_references(merged_references, key, value) - merged_references.select { |_, v| v[0].nil? and not v[1].nil? } - .each_with_object({}) { |k, h| h["#{key}_#{k[0]}"] = [nil, k[1][1][value]] } + merged_references.select { |_, (old_attributes, new_attributes)| + old_attributes.nil? && !new_attributes.nil? + }.each_with_object({}) { |(id, (_, new_attributes)), result| + result["#{key}_#{id}"] = [nil, new_attributes[value]] + } end def self.removed_references(merged_references, key, value) - merged_references.select { |_, v| not v[0].nil? and v[1].nil? } - .each_with_object({}) { |k, h| h["#{key}_#{k[0]}"] = [k[1][0][value], nil] } + merged_references.select { |_, (old_attributes, new_attributes)| + !old_attributes.nil? && new_attributes.nil? + }.each_with_object({}) { |(id, (old_attributes, _)), result| + result["#{key}_#{id}"] = [old_attributes[value], nil] + } end def self.changed_references(merged_references, key, value) - merged_references.select { |_, v| not v[0].nil? and not v[1].nil? and v[0][value] != v[1][value] } - .each_with_object({}) { |k, h| h["#{key}_#{k[0]}"] = [k[1][0][value], k[1][1][value]] } + merged_references.select { |_, (old_attributes, new_attributes)| + !old_attributes.nil? && !new_attributes.nil? && old_attributes[value] != new_attributes[value] + }.each_with_object({}) { |(id, (old_attributes, new_attributes)), result| + result["#{key}_#{id}"] = [old_attributes[value], new_attributes[value]] + } end def self.recreate_initial_journal(type, journal, changed_data) @@ -136,7 +156,7 @@ class JournalManager journable_type: journal_class_name(journable.class), version: version, activity_type: journable.send(:activity_type), - changed_data: journable.attributes.symbolize_keys } + details: journable.attributes.symbolize_keys } create_journal journable, journal_attributes, user, notes end @@ -146,17 +166,19 @@ class JournalManager type = base_class(journable.class) extended_journal_attributes = journal_attributes.merge(journable_type: type.to_s) .merge(notes: notes) - .except(:changed_data) + .except(:details) .except(:id) unless extended_journal_attributes.has_key? :user_id extended_journal_attributes[:user_id] = user.id end - journal_attributes[:changed_data] = normalize_newlines(journal_attributes[:changed_data]) + journal_attributes[:details] = normalize_newlines(journal_attributes[:details]) journal = journable.journals.build extended_journal_attributes - journal.data = create_journal_data journal.id, type, valid_journal_attributes(type, journal_attributes[:changed_data]) + journal.data = create_journal_data journal.id, + type, + valid_journal_attributes(type, journal_attributes[:details]) create_association_data journable, journal @@ -253,4 +275,8 @@ class JournalManager journal.customizable_journals.build custom_field_id: cv.custom_field_id, value: cv.value end end + + def self.reset_notification + @send_notification = true + end end diff --git a/app/models/journal_notification_mailer.rb b/app/models/journal_notification_mailer.rb new file mode 100644 index 00000000000..4a28cae6820 --- /dev/null +++ b/app/models/journal_notification_mailer.rb @@ -0,0 +1,86 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +class JournalNotificationMailer + class << self + def distinguish_journals(journal, send_notification) + if send_notification + if journal.journable_type == 'WorkPackage' + handle_work_package_journal(journal) + end + end + end + + def handle_work_package_journal(journal) + return nil unless send_notification? journal + + aggregated = find_aggregated_journal_for(journal) + + # Send the notification on behalf of the predecessor in case it could not send it on its own + if Journal::AggregatedJournal.hides_notifications?(aggregated, aggregated.predecessor) + job = DeliverWorkPackageNotificationJob.new(aggregated.predecessor.id, User.current.id) + Delayed::Job.enqueue job + end + + job = EnqueueWorkPackageNotificationJob.new(journal.id, User.current.id) + Delayed::Job.enqueue job, run_at: delivery_time + end + + def send_notification?(journal) + (Setting.notified_events.include?('work_package_added') && journal.initial?) || + (Setting.notified_events.include?('work_package_updated') && !journal.initial?) || + notify_for_notes?(journal) || + notify_for_status?(journal) || + notify_for_priority(journal) + end + + def notify_for_notes?(journal) + Setting.notified_events.include?('work_package_note_added') && journal.notes.present? + end + + def notify_for_status?(journal) + Setting.notified_events.include?('status_updated') && + journal.details.has_key?(:status_id) + end + + def notify_for_priority(journal) + Setting.notified_events.include?('work_package_priority_updated') && + journal.details.has_key?(:priority_id) + end + + def delivery_time + Setting.journal_aggregation_time_minutes.to_i.minutes.from_now + end + + def find_aggregated_journal_for(raw_journal) + wp_journals = Journal::AggregatedJournal.aggregated_journals(journable: raw_journal.journable) + wp_journals.detect { |journal| journal.version == raw_journal.version } + end + end +end diff --git a/app/models/journal_observer.rb b/app/models/journal_observer.rb deleted file mode 100644 index 7c38b0be018..00000000000 --- a/app/models/journal_observer.rb +++ /dev/null @@ -1,67 +0,0 @@ -#-- encoding: UTF-8 -#-- copyright -# OpenProject is a project management system. -# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See doc/COPYRIGHT.rdoc for more details. -#++ - -class JournalObserver < ActiveRecord::Observer - attr_accessor :send_notification - - def after_create(journal) - if journal.journable_type == 'WorkPackage' and !journal.initial? and send_notification - after_create_issue_journal(journal) - end - clear_notification - end - - def after_create_issue_journal(journal) - if Setting.notified_events.include?('work_package_updated') || - (Setting.notified_events.include?('work_package_note_added') && journal.notes.present?) || - (Setting.notified_events.include?('status_updated') && journal.changed_data.has_key?(:status_id)) || - (Setting.notified_events.include?('work_package_priority_updated') && journal.changed_data.has_key?(:priority_id)) - issue = journal.journable - recipients = issue.recipients + issue.watcher_recipients - users = User.find_all_by_mails(recipients.uniq) - users.each do |user| - job = DeliverWorkPackageUpdatedJob.new(user.id, journal.id, User.current.id) - Delayed::Job.enqueue job - end - end - end - - # Wrap send_notification so it defaults to true, when it's nil - def send_notification - return true if @send_notification.nil? - @send_notification - end - - private - - # Need to clear the notification setting after each usage otherwise it might be cached - def clear_notification - @send_notification = true - end -end diff --git a/app/models/ldap_auth_source.rb b/app/models/ldap_auth_source.rb index 9fbdd6111f6..9ff046cef36 100644 --- a/app/models/ldap_auth_source.rb +++ b/app/models/ldap_auth_source.rb @@ -53,10 +53,11 @@ class LdapAuthSource < AuthSource # test the connection to the LDAP def test_connection - ldap_con = initialize_ldap_con(account, account_password) - ldap_con.open {} + unless authenticate_dn(account, account_password) + raise I18n.t('auth_source.ldap_error', error_message: I18n.t('auth_source.ldap_auth_failed')) + end rescue Net::LDAP::LdapError => text - raise 'LdapError: ' + text.to_s + raise I18n.t('auth_source.ldap_error', error_message: text.to_s) end def auth_method_name diff --git a/app/models/legacy_journal.rb b/app/models/legacy_journal.rb index ca6d665123d..b192856108c 100644 --- a/app/models/legacy_journal.rb +++ b/app/models/legacy_journal.rb @@ -115,6 +115,7 @@ class LegacyJournal < ActiveRecord::Base attributes['changed_data'] || {} end + # TODO Evaluate whether this can be removed without disturbing any migrations alias_method :changed_data, :details def new_value_for(prop) diff --git a/app/models/message_observer.rb b/app/models/message_observer.rb index 40621f2d37e..c3eb4311b65 100644 --- a/app/models/message_observer.rb +++ b/app/models/message_observer.rb @@ -33,8 +33,7 @@ class MessageObserver < ActiveRecord::Observer recipients = message.recipients recipients += message.root.watcher_recipients recipients += message.board.watcher_recipients - users = User.find_all_by_mails(recipients.uniq) - users.each do |user| + recipients.uniq.each do |user| UserMailer.message_posted(user, message, User.current).deliver end end diff --git a/app/models/news_observer.rb b/app/models/news_observer.rb index fac62827e4d..8499184eed5 100644 --- a/app/models/news_observer.rb +++ b/app/models/news_observer.rb @@ -30,8 +30,7 @@ class NewsObserver < ActiveRecord::Observer def after_create(news) if Setting.notified_events.include?('news_added') - users = User.find_all_by_mails(news.recipients) - users.each do |user| + news.recipients.uniq.each do |user| UserMailer.news_added(user, news, User.current).deliver end end diff --git a/app/models/planning_element_type_color.rb b/app/models/planning_element_type_color.rb index 6af3668789d..c68e3d11482 100644 --- a/app/models/planning_element_type_color.rb +++ b/app/models/planning_element_type_color.rb @@ -46,25 +46,6 @@ class PlanningElementTypeColor < ActiveRecord::Base validates_length_of :name, maximum: 255, unless: lambda { |e| e.name.blank? } validates_format_of :hexcode, with: /\A#[0-9A-F]{6}\z/, unless: lambda { |e| e.hexcode.blank? } - def self.colors - [ - find_or_initialize_by(name: 'Black', hexcode: '#000000'), - find_or_initialize_by(name: 'White', hexcode: '#FFFFFF'), - find_or_initialize_by(name: 'Blue', hexcode: '#3399CC'), - find_or_initialize_by(name: 'Mint', hexcode: '#66CCCC'), - find_or_initialize_by(name: 'Lime', hexcode: '#66CC99'), - find_or_initialize_by(name: 'Green-neon', hexcode: '#00CC33'), - find_or_initialize_by(name: 'Green', hexcode: '#339933'), - find_or_initialize_by(name: 'Orange', hexcode: '#FFCC00'), - find_or_initialize_by(name: 'Red', hexcode: '#CC3333'), - find_or_initialize_by(name: 'Red-bright', hexcode: '#FF3300'), - find_or_initialize_by(name: 'Yellow', hexcode: '#FFFF00'), - find_or_initialize_by(name: 'Purple', hexcode: '#CC0066'), - find_or_initialize_by(name: 'Grey-dark', hexcode: '#666666'), - find_or_initialize_by(name: 'Grey-light', hexcode: '#DDDDDD') - ] - end - def text_hexcode # 0.63 - Optimal threshold to switch between white and black text color # determined by intensive user tests and expensive research diff --git a/app/models/project.rb b/app/models/project.rb index aa8936524d6..d253e30be23 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -108,6 +108,9 @@ class Project < ActiveRecord::Base author: nil, datetime: :created_on + acts_as_countable :required_project_storage, + countable: [:work_packages, :repository] + attr_protected :status validates_presence_of :name, :identifier @@ -632,9 +635,9 @@ class Project < ActiveRecord::Base possible_responsible_members.map(&:principal).compact.sort end - # Returns the mail adresses of users that should be always notified on project events + # Returns users that should be always notified on project events def recipients - notified_users.map(&:mail) + notified_users end # Returns the users that should be notified on project events @@ -943,6 +946,24 @@ class Project < ActiveRecord::Base update_attribute :status, STATUS_ARCHIVED end + def required_storage + Rails.cache.fetch("project##{id}/project_storage", + expires_in: self.class.project_storage_expires_in) do + count_for(:required_project_storage).first + end + end + + def self.total_projects_size + Rails.cache.fetch('projects/total_projects_size', + expires_in: project_storage_expires_in) do + Project.all.map { |p| p.required_storage[:total] }.sum + end + end + + def self.project_storage_expires_in + Setting.project_storage_cache_minutes.to_i.minutes + end + protected def self.possible_principles_condition diff --git a/app/models/queries/work_packages/available_filter_options.rb b/app/models/queries/work_packages/available_filter_options.rb index 6f77798c436..82cf6f3cb48 100644 --- a/app/models/queries/work_packages/available_filter_options.rb +++ b/app/models/queries/work_packages/available_filter_options.rb @@ -99,7 +99,7 @@ module Queries::WorkPackages::AvailableFilterOptions @available_work_package_filters = { status_id: { type: :list_status, order: 1, values: Status.all.map { |s| [s.name, s.id.to_s] } }, type_id: { type: :list, order: 2, values: types.map { |s| [s.name, s.id.to_s] } }, - priority_id: { type: :list, order: 3, values: IssuePriority.all.map { |s| [s.name, s.id.to_s] } }, + priority_id: { type: :list, order: 3, values: IssuePriority.active.map { |s| [s.name, s.id.to_s] } }, subject: { type: :text, order: 8 }, created_at: { type: :date_past, order: 9 }, updated_at: { type: :date_past, order: 10 }, diff --git a/app/models/repository.rb b/app/models/repository.rb index 847c0343d67..ace6e36de9f 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -29,6 +29,7 @@ class Repository < ActiveRecord::Base include Redmine::Ciphering + include OpenProject::Scm::ManageableRepository belongs_to :project has_many :changesets, -> { @@ -37,6 +38,10 @@ class Repository < ActiveRecord::Base before_save :sanitize_urls + # Managed repository lifetime + after_create :create_managed_repository, if: Proc.new { |repo| repo.managed? } + after_destroy :delete_managed_repository, if: Proc.new { |repo| repo.managed? } + # Raw SQL to delete changesets and changes in the database # has_many :changesets, :dependent => :destroy is too slow for big repositories before_destroy :clear_changesets @@ -46,6 +51,10 @@ class Repository < ActiveRecord::Base validates_length_of :password, maximum: 255, allow_nil: true validate :validate_enabled_scm, on: :create + acts_as_countable :required_project_storage, + label: 'label_repository', + countable: :required_disk_storage + def changes Change.where(changeset_id: changesets).joins(:changeset) end @@ -80,12 +89,32 @@ class Repository < ActiveRecord::Base def scm @scm ||= scm_adapter.new(url, root_url, login, password, path_encoding) - update_attribute(:root_url, @scm.root_url) if root_url.blank? + + # override the adapter's root url with the full url + # if none other was set. + unless @scm.root_url.present? + @scm.root_url = root_url.presence || url + end + @scm end - def scm_name - self.class.scm_name + def self.scm_config + scm_adapter_class.config + end + + def self.available_types + supported_types - disabled_types + end + + ## + # Retrieves the :disabled_types setting from `configuration.yml + def self.disabled_types + scm_config[:disabled_types] || [] + end + + def vendor + self.class.vendor end def supports_cat? @@ -148,6 +177,19 @@ class Repository < ActiveRecord::Base path end + def required_disk_storage + if scm.storage_available? + oldest_cachable_time = Setting.repository_storage_cache_minutes.to_i.minutes.ago + if storage_updated_at.nil? || + storage_updated_at < oldest_cachable_time + + Delayed::Job.enqueue ::Scm::StorageUpdaterJob.new(self) + end + + required_storage_bytes + end + end + # Finds and returns a revision with a number or the beginning of a hash def find_changeset_by_name(name) name = name.to_s @@ -240,7 +282,7 @@ class Repository < ActiveRecord::Base if project.repository begin project.repository.fetch_changesets - rescue Redmine::Scm::Adapters::CommandFailed => e + rescue OpenProject::Scm::Exceptions::CommandFailed => e logger.error "scm: error during fetching changesets: #{e.message}" end end @@ -252,61 +294,83 @@ class Repository < ActiveRecord::Base all.each(&:scan_changesets_for_work_package_ids) end - def self.scm_name - 'Abstract' + + ## + # Builds a model instance of type +Repository::#{vendor}+ with the given parameters. + # + # @param [Project] project The project this repository belongs to. + # @param [String] vendor The SCM vendor name (e.g., Git, Subversion) + # @param [Hash] params Custom parameters for this SCM as delivered from the repository + # field. + # + # @param [Symbol] type SCM tag to determine the type this repository should be built as + # + # @raise [OpenProject::Scm::RepositoryBuildError] + # Raised when the instance could not be built + # given the parameters. + # @raise [::NameError] Raised when the given +vendor+ could not be resolved to a class. + def self.build(project, vendor, params, type) + klass = build_scm_class(vendor) + + # We can't possibly know the form fields this particular vendor + # desires, so we allow it to filter them from raw params + # before building the instance with it. + args = klass.permitted_params(params) + + repository = klass.new(args) + repository.attributes = args + repository.project = project + + set_verified_type!(repository, type) unless type.nil? + + repository.configure(type, args) + + repository end - def self.available_scm - subclasses.map { |klass| [klass.scm_name, klass.name] } + ## + # Build a temporary model instance of the given vendor for temporary use in forms. + # Will not receive any args. + def self.build_scm_class(vendor) + klass = OpenProject::Scm::Manager.registered[vendor] + + if klass.nil? + raise OpenProject::Scm::Exceptions::RepositoryBuildError.new( + I18n.t('repositories.errors.disabled_or_unknown_vendor', vendor: vendor) + ) + else + klass + end end - def self.factory(klass_name, *args) - klass = "Repository::#{klass_name}".constantize - klass.new(*args) - rescue - nil + ## + # Verifies that the chosen scm type can be selected + def self.set_verified_type!(repository, type) + if repository.class.available_types.include? type + repository.scm_type = type + else + raise OpenProject::Scm::Exceptions::RepositoryBuildError.new( + I18n.t('repositories.errors.disabled_or_unknown_type', + type: type, + vendor: repository.vendor) + ) + end + end + + ## + # Allow global permittible params. May be overridden by plugins + def self.permitted_params(params) + params.permit(:url) end def self.scm_adapter_class nil end - def self.scm_command - ret = '' - begin - ret = scm_adapter_class.client_command if scm_adapter_class - rescue Redmine::Scm::Adapters::CommandFailed => e - logger.error "scm: error during get command: #{e.message}" - end - ret + def self.vendor + name.demodulize end - def self.scm_version_string - ret = '' - begin - ret = scm_adapter_class.client_version_string if scm_adapter_class - rescue Redmine::Scm::Adapters::CommandFailed => e - logger.error "scm: error during get version string: #{e.message}" - end - ret - end - - def self.scm_available - ret = false - begin - ret = scm_adapter_class.client_available if scm_adapter_class - rescue Redmine::Scm::Adapters::CommandFailed => e - logger.error "scm: error during get scm available: #{e.message}" - end - ret - end - - def self.configured? - true - end - - private - # Strips url and root_url def sanitize_urls url.strip! if url.present? @@ -322,4 +386,32 @@ class Repository < ActiveRecord::Base self.class.connection.delete("DELETE FROM #{ci} WHERE #{ci}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})") self.class.connection.delete("DELETE FROM #{cs} WHERE #{cs}.repository_id = #{id}") end + + private + + ## + # Create local managed repository request when the built instance + # is managed by OpenProject + def create_managed_repository + service = Scm::CreateManagedRepositoryService.new(self) + if service.call + true + else + raise OpenProject::Scm::Exceptions::RepositoryBuildError.new( + service.localized_rejected_reason + ) + end + end + + ## + # Destroy local managed repository request when the built instance + # is managed by OpenProject + def delete_managed_repository + service = Scm::DeleteManagedRepositoryService.new(self) + # Even if the service can't remove the physical repository, + # we should continue removing the associated instance. + service.call + + true + end end diff --git a/app/models/repository/filesystem.rb b/app/models/repository/filesystem.rb deleted file mode 100644 index b739abed64d..00000000000 --- a/app/models/repository/filesystem.rb +++ /dev/null @@ -1,100 +0,0 @@ -#-- encoding: UTF-8 -#-- copyright -# OpenProject is a project management system. -# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See doc/COPYRIGHT.rdoc for more details. -#++ - -require 'redmine/scm/adapters/filesystem_adapter' - -class Repository::Filesystem < Repository - attr_protected :root_url - validates_presence_of :url - - validate :validate_whitelisted_url, - :validate_url_is_dir - - ATTRIBUTE_KEY_NAMES = { - 'url' => 'Root directory', - } - def self.human_attribute_name(attribute_key_name, options = {}) - ATTRIBUTE_KEY_NAMES[attribute_key_name] || super - end - - def self.scm_adapter_class - Redmine::Scm::Adapters::FilesystemAdapter - end - - def self.scm_name - 'Filesystem' - end - - def self.configured? - !whitelisted_paths.empty? - end - - def supports_all_revisions? - false - end - - def entries(path = nil, identifier = nil) - scm.entries(path, identifier) - end - - def fetch_changesets - nil - end - - private - - def self.whitelisted_paths - OpenProject::Configuration['scm_filesystem_path_whitelist'] - end - - # validates that the url is a directory - def validate_url_is_dir - errors.add :url, :no_directory unless Dir.exists?(url) - end - - # validate url against whitelisted urls as provided by the - # scm_filesystem_path_whitelist configuration parameter. - # - # The url needs to exist and needs to match one of the directories - # returned when globbing the configuration setting. - def validate_whitelisted_url - globbed_url = Dir.glob(url).first - - unless globbed_url - errors.add :url, :not_whitelisted - return - end - - globbed_whitelisted = Dir.glob(self.class.whitelisted_paths) - - unless globbed_whitelisted.include?(globbed_url) - errors.add :url, :not_whitelisted - end - end -end diff --git a/app/models/repository/git.rb b/app/models/repository/git.rb index 0bcec746adf..948358a508d 100644 --- a/app/models/repository/git.rb +++ b/app/models/repository/git.rb @@ -27,25 +27,53 @@ # See doc/COPYRIGHT.rdoc for more details. #++ -require 'redmine/scm/adapters/git_adapter' +require 'open_project/scm/adapters/git' class Repository::Git < Repository attr_protected :root_url validates_presence_of :url - ATTRIBUTE_KEY_NAMES = { - 'url' => 'Path to repository', - } - def self.human_attribute_name(attribute_key_name, options = {}) - ATTRIBUTE_KEY_NAMES[attribute_key_name] || super - end - def self.scm_adapter_class - Redmine::Scm::Adapters::GitAdapter + OpenProject::Scm::Adapters::Git end - def self.scm_name - 'Git' + def configure(scm_type, _args) + if scm_type == self.class.managed_type + unless manageable? + raise OpenProject::Scm::Exceptions::RepositoryBuildError.new( + I18n.t('repositories.managed.error_not_manageable') + ) + end + + self.root_url = managed_repository_path + self.url = managed_repository_url + end + end + + def self.permitted_params(params) + super(params).merge(params.permit(:path_encoding)) + end + + def self.supported_types + types = [:local] + types << managed_type if manageable? + + types + end + + def managed_repo_created + scm.initialize_bare_git + end + + def repository_identifier + "#{super}.git" + end + + ## + # Git doesn't like local urls when visiting + # the repository, thus always use the path. + def managed_repository_url + managed_repository_path end def supports_directory_revisions? diff --git a/app/models/repository/subversion.rb b/app/models/repository/subversion.rb index 18a7010fafb..06f448dea0a 100644 --- a/app/models/repository/subversion.rb +++ b/app/models/repository/subversion.rb @@ -27,7 +27,7 @@ # See doc/COPYRIGHT.rdoc for more details. #++ -require 'redmine/scm/adapters/subversion_adapter' +require 'open_project/scm/adapters/subversion' class Repository::Subversion < Repository attr_protected :root_url @@ -35,11 +35,35 @@ class Repository::Subversion < Repository validates_format_of :url, with: /\A(http|https|svn(\+[^\s:\/\\]+)?|file):\/\/.+\z/i def self.scm_adapter_class - Redmine::Scm::Adapters::SubversionAdapter + OpenProject::Scm::Adapters::Subversion end - def self.scm_name - 'Subversion' + def configure(scm_type, _args) + if scm_type == self.class.managed_type + unless manageable? + raise OpenProject::Scm::Exceptions::RepositoryBuildError.new( + I18n.t('repositories.managed.error_not_manageable') + ) + end + + self.root_url = managed_repository_path + self.url = managed_repository_url + end + end + + def self.permitted_params(params) + super(params).merge(params.permit(:login, :password)) + end + + def self.supported_types + types = [:existing] + types << managed_type if manageable? + + types + end + + def managed_repo_created + scm.create_empty_svn end def supports_directory_revisions? diff --git a/app/models/user.rb b/app/models/user.rb index 33af8414d9c..1ede9e90046 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -710,6 +710,14 @@ class User < Principal @current_user ||= User.anonymous end + def self.execute_as(user) + previous_user = User.current + User.current = user + yield + ensure + User.current = previous_user + end + def roles(project) User.current.admin? ? Role.all : User.current.roles_for_project(project) end diff --git a/app/models/wiki_content.rb b/app/models/wiki_content.rb index 2dbcda6c059..a566694f4eb 100644 --- a/app/models/wiki_content.rb +++ b/app/models/wiki_content.rb @@ -68,8 +68,7 @@ class WikiContent < ActiveRecord::Base # Returns the mail adresses of users that should be notified def recipients notified = project.notified_users - notified.reject! do |user| !visible?(user) end - notified.map(&:mail) + notified.select { |user| visible?(user) } end # FIXME: Deprecate @@ -87,57 +86,4 @@ class WikiContent < ActiveRecord::Base def comments_to_journal_notes add_journal author, comments end - - # FIXME: This is for backwards compatibility only. Remove once we decide it is not needed anymore - # WikiContentJournal.class_eval do - # attr_protected :data - # after_save :compress_version_text - # - # # Wiki Content might be large and the data should possibly be compressed - # def compress_version_text - # self.text = changed_data["text"].last if changed_data["text"] - # self.text ||= self.journaled.text - # end - # - # def text=(plain) - # case Setting.wiki_compression - # when "gzip" - # begin - # text_hash :text => Zlib::Deflate.deflate(plain, Zlib::BEST_COMPRESSION), :compression => Setting.wiki_compression - # rescue - # text_hash :text => plain, :compression => '' - # end - # else - # text_hash :text => plain, :compression => '' - # end - # plain - # end - # - # def text_hash(hash) - # changed_data.delete("text") - # changed_data["data"] = hash[:text] - # changed_data["compression"] = hash[:compression] - # update_attribute(:changed_data, changed_data) - # end - # - # def text - # @text ||= case changed_data["compression"] - # when "gzip" - # Zlib::Inflate.inflate(changed_data["data"]) - # else - # # uncompressed data - # changed_data["data"] - # end - # end - # - # # Returns the previous version or nil - # def previous - # @previous ||= journaled.journals.at(version - 1) - # end - # - # # FIXME: Deprecate - # def versioned - # journaled - # end - # end end diff --git a/app/models/wiki_content_observer.rb b/app/models/wiki_content_observer.rb index 0091a7802a9..4499c686747 100644 --- a/app/models/wiki_content_observer.rb +++ b/app/models/wiki_content_observer.rb @@ -31,8 +31,7 @@ class WikiContentObserver < ActiveRecord::Observer def after_create(wiki_content) if Setting.notified_events.include?('wiki_content_added') recipients = wiki_content.recipients + wiki_content.page.wiki.watcher_recipients - users = User.find_all_by_mails(recipients.uniq) - users.each do |user| + recipients.uniq.each do |user| UserMailer.wiki_content_added(user, wiki_content, User.current).deliver end end @@ -40,9 +39,10 @@ class WikiContentObserver < ActiveRecord::Observer def after_update(wiki_content) if wiki_content.text_changed? && Setting.notified_events.include?('wiki_content_updated') - recipients = wiki_content.recipients + wiki_content.page.wiki.watcher_recipients + wiki_content.page.watcher_recipients - users = User.find_all_by_mails(recipients.uniq) - users.each do |user| + recipients = wiki_content.recipients + recipients += wiki_content.page.wiki.watcher_recipients + recipients += wiki_content.page.watcher_recipients + recipients.uniq.each do |user| UserMailer.wiki_content_updated(user, wiki_content, User.current).deliver end end diff --git a/app/models/work_package.rb b/app/models/work_package.rb index 90d4e264881..275df802b39 100644 --- a/app/models/work_package.rb +++ b/app/models/work_package.rb @@ -180,6 +180,11 @@ class WorkPackage < ActiveRecord::Base acts_as_journalized except: ['root_id'] + acts_as_countable :required_project_storage, + label: 'attributes.attachments', + collection: true, + countable: Proc.new { joins(:attachments).sum(:filesize).to_i } + # This one is here only to ease reading module JournalizedProcs def self.event_title @@ -202,7 +207,7 @@ class WorkPackage < ActiveRecord::Base journal = o.last_journal t = 'work_package' - t << if journal && journal.changed_data.empty? && !journal.initial? + t << if journal && journal.details.empty? && !journal.initial? '-note' else status = Status.find_by(id: o.status_id) @@ -432,6 +437,7 @@ class WorkPackage < ActiveRecord::Base # TODO: move into Business Object and rename to update # update for now is a private method defined by AR def update_by!(user, attributes) + attributes = attributes.dup raw_attachments = attributes.delete(:attachments) update_by(user, attributes) @@ -479,16 +485,8 @@ class WorkPackage < ActiveRecord::Base @spent_hours ||= compute_spent_hours(usr) end - # Moves/copies an work_package to a new project and type - # Returns the moved/copied work_package on success, false on failure - def move_to_project(*args) - WorkPackage.transaction do - move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback) - end || false - end - # >>> issues.rb >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> - # Returns the mail addresses of users that should be notified + # Returns users that should be notified def recipients notified = project.notified_users # Author and assignee are always notified unless they have been @@ -503,8 +501,7 @@ class WorkPackage < ActiveRecord::Base end notified.uniq! # Remove users that can not view the issue - notified.reject! do |user| !visible?(user) end - notified.map(&:mail) + notified.select { |user| visible?(user) } end def done_ratio @@ -667,77 +664,6 @@ class WorkPackage < ActiveRecord::Base done_date <= Date.today end - def move_to_project_without_transaction(new_project, new_type = nil, options = {}) - options ||= {} - work_package = options[:copy] ? self.class.new.copy_from(self) : self - - if new_project && work_package.project_id != new_project.id - delete_relations(work_package) - # work_package is moved to another project - # reassign to the category with same name if any - new_category = if work_package.category.nil? - nil - else - new_project.categories.find_by(name: work_package.category.name) - end - work_package.category = new_category - # Keep the fixed_version if it's still valid in the new_project - unless new_project.shared_versions.include?(work_package.fixed_version) - work_package.fixed_version = nil - end - - work_package.project = new_project - - enforce_cross_project_settings(work_package) - end - if new_type - work_package.type = new_type - work_package.reset_custom_values! - end - # Allow bulk setting of attributes on the work_package - if options[:attributes] - # before setting the attributes, we need to remove the move-related fields - work_package.attributes = - options[:attributes].except(:copy, :new_project_id, :new_type_id, :follow, :ids) - .reject { |_key, value| value.blank? } - end # FIXME this eliminates the case, where values shall be bulk-assigned to null, - # but this needs to work together with the permit - if options[:copy] - work_package.author = User.current - work_package.custom_field_values = - custom_field_values.inject({}) do |h, v| - h[v.custom_field_id] = v.value - h - end - work_package.status = if options[:attributes] && options[:attributes][:status_id].present? - Status.find_by(id: options[:attributes][:status_id]) - else - status - end - else - work_package.add_journal User.current, options[:journal_note] if options[:journal_note] - end - - if work_package.save - if options[:copy] - create_and_save_journal_note work_package, options[:journal_note] - else - # Manually update project_id on related time entries - TimeEntry.where(work_package_id: id).update_all("project_id = #{new_project.id}") - - work_package.children.each do |child| - unless child.move_to_project_without_transaction(new_project) - # Move failed and transaction was rollback'd - return false - end - end - end - else - return false - end - work_package - end - # check if user is allowed to edit WorkPackage Journals. # see Redmine::Acts::Journalized::Permissions#journal_editable_by def editable_by?(user) @@ -842,22 +768,10 @@ class WorkPackage < ActiveRecord::Base reload(select: [:lock_version, :created_at, :updated_at]) end - # Returns an array of projects that current user can move issues to - def self.allowed_target_projects_on_move - projects = [] - if User.current.admin? - # admin is allowed to move issues to any active (visible) project - projects = Project.visible - elsif User.current.logged? - if Role.non_member.allowed_to?(:move_work_packages) - projects = Project.visible - else - User.current.memberships.each do |m| - projects << m.project if m.roles.detect { |r| r.allowed_to?(:move_work_packages) } - end - end - end - projects + # Returns a scope for the projects + # the user is allowed to move a work package to + def self.allowed_target_projects_on_move(user) + Project.where(Project.allowed_to_condition(user, :move_work_packages)) end # Do not redefine alias chain on reload (see #4838) @@ -1105,21 +1019,6 @@ class WorkPackage < ActiveRecord::Base end end - def create_and_save_journal_note(work_package, journal_note) - if work_package && journal_note - work_package.add_journal User.current, journal_note - work_package.save! - end - end - - def enforce_cross_project_settings(work_package) - parent_in_project = - work_package.parent.nil? || work_package.parent.project == work_package.project - - work_package.parent_id = - nil unless Setting.cross_project_work_package_relations? || parent_in_project - end - def compute_spent_hours(usr = User.current) spent_time = TimeEntry.visible(usr) .on_work_packages(self_and_descendants.visible(usr)) diff --git a/app/models/work_package_observer.rb b/app/services/add_attachment_service.rb similarity index 58% rename from app/models/work_package_observer.rb rename to app/services/add_attachment_service.rb index 6570b6749a3..5649ed111e2 100644 --- a/app/models/work_package_observer.rb +++ b/app/services/add_attachment_service.rb @@ -1,4 +1,3 @@ -#-- encoding: UTF-8 #-- copyright # OpenProject is a project management system. # Copyright (C) 2012-2015 the OpenProject Foundation (OPF) @@ -27,39 +26,38 @@ # See doc/COPYRIGHT.rdoc for more details. #++ -class WorkPackageObserver < ActiveRecord::Observer - attr_accessor :send_notification +class AddAttachmentService + attr_reader :container, :author - def after_create(work_package) - if send_notification - recipients = work_package.recipients + work_package.watcher_recipients - users = User.find_all_by_mails(recipients.uniq) - - users.each do |user| - notify(user, work_package) - end - end - clear_notification + def initialize(container, author:) + @container = container + @author = author end ## - # Notifies the user of the created work package. - def notify(user, work_package) - job = DeliverWorkPackageCreatedJob.new(user.id, work_package.id, User.current.id) + # Adds and saves the uploaded file as attachment of the given container. + # In case the container supports it, a journal will be written. + # + # An ActiveRecord::RecordInvalid error is raised if any record can't be saved. + def add_attachment(uploaded_file:, description:) + attachment = Attachment.new(file: uploaded_file, + container: container, + description: description, + author: author) + save attachment - Delayed::Job.enqueue job - end - - # Wrap send_notification so it defaults to true, when it's nil - def send_notification - return true if @send_notification.nil? - @send_notification + attachment end private - # Need to clear the notification setting after each usage otherwise it might be cached - def clear_notification - @send_notification = true + def save(attachment) + ActiveRecord::Base.transaction do + attachment.save! + if container.respond_to? :add_journal + container.add_journal author + container.save! + end + end end end diff --git a/app/services/create_work_package_service.rb b/app/services/create_work_package_service.rb index 15f92033f25..d38c731e79d 100644 --- a/app/services/create_work_package_service.rb +++ b/app/services/create_work_package_service.rb @@ -34,7 +34,7 @@ class CreateWorkPackageService self.user = user self.project = project - WorkPackageObserver.instance.send_notification = send_notifications + JournalManager.send_notification = send_notifications end def create diff --git a/app/services/move_work_package_service.rb b/app/services/move_work_package_service.rb new file mode 100644 index 00000000000..e4fc2389892 --- /dev/null +++ b/app/services/move_work_package_service.rb @@ -0,0 +1,184 @@ + +# Moves/copies an work_package to a new project and type +# Returns the moved/copied work_package on success, false on failure + +class MoveWorkPackageService + attr_accessor :work_package, + :user + + def initialize(work_package, user) + self.work_package = work_package + self.user = user + end + + def call(new_project, new_type = nil, options = {}) + if options[:no_transaction] + move_without_transaction(new_project, new_type, options) + else + WorkPackage.transaction do + move_without_transaction(new_project, new_type, options) || + raise(ActiveRecord::Rollback) + end || false + end + end + + private + + def move_without_transaction(new_project, new_type = nil, options = {}) + attributes = options[:attributes] || {} + + modified_work_package = copy_or_move(options[:copy], new_project, new_type, attributes) + + if options[:copy] + return false unless copy(modified_work_package, attributes, options) + else + return false unless move(modified_work_package, new_project, options) + end + + modified_work_package + end + + def copy_or_move(make_copy, new_project, new_type, attributes) + modified_work_package = if make_copy + WorkPackage.new.copy_from(work_package) + else + work_package + end + + move_to_project(modified_work_package, new_project) + + move_to_type(modified_work_package, new_type) + + bulk_assign_attributes(modified_work_package, attributes) + + modified_work_package + end + + def copy(modified_work_package, attributes, options) + set_default_values_on_copy(modified_work_package, attributes) + + return false unless modified_work_package.save + + create_and_save_journal_note modified_work_package, options[:journal_note] + + true + end + + def move(modified_work_package, new_project, options) + if options[:journal_note] + modified_work_package.add_journal user, options[:journal_note] + end + + return false unless modified_work_package.save + + move_time_entries(modified_work_package, new_project) + + return false unless move_children(modified_work_package, new_project, options) + + true + end + + def move_to_project(work_package, new_project) + if new_project && + work_package.project_id != new_project.id && + allowed_to_move_to_project?(new_project) + + work_package.delete_relations(work_package) + + reassign_category(work_package, new_project) + + # Keep the fixed_version if it's still valid in the new_project + unless new_project.shared_versions.include?(work_package.fixed_version) + work_package.fixed_version = nil + end + + work_package.project = new_project + + enforce_cross_project_settings(work_package) + end + end + + def move_to_type(work_package, new_type) + if new_type + work_package.type = new_type + work_package.reset_custom_values! + end + end + + def bulk_assign_attributes(work_package, attributes) + # Allow bulk setting of attributes on the work_package + if attributes + # before setting the attributes, we need to remove the move-related fields + work_package.attributes = + attributes.except(:copy, :new_project_id, :new_type_id, :follow, :ids) + .reject { |_key, value| value.blank? } + end # FIXME this eliminates the case, where values shall be bulk-assigned to null, + # but this needs to work together with the permit + end + + def set_default_values_on_copy(work_package, attributes) + work_package.author = user + + assign_status_or_default(work_package, attributes[:status_id]) + end + + def move_children(work_package, new_project, options) + work_package.children.each do |child| + child_service = self.class.new(child, user) + unless child_service.call(new_project, nil, options.merge(no_transaction: true)) + # Move failed and transaction was rollback'd + return false + end + end + + true + end + + def move_time_entries(work_package, new_project) + # Manually update project_id on related time entries + TimeEntry.update_all("project_id = #{new_project.id}", work_package_id: work_package.id) + end + + def enforce_cross_project_settings(work_package) + parent_in_project = + work_package.parent.nil? || work_package.parent.project == work_package.project + + work_package.parent_id = + nil unless Setting.cross_project_work_package_relations? || parent_in_project + end + + def create_and_save_journal_note(work_package, journal_note) + if journal_note + work_package.add_journal user, journal_note + work_package.save! + end + end + + def allowed_to_move_to_project?(new_project) + WorkPackage + .allowed_target_projects_on_move(user) + .where(id: new_project.id) + .exists? + end + + def reassign_category(work_package, new_project) + # work_package is moved to another project + # reassign to the category with same name if any + new_category = if work_package.category.nil? + nil + else + new_project.categories.find_by_name(work_package.category.name) + end + work_package.category = new_category + end + + def assign_status_or_default(work_package, status_id) + status = if status_id.present? + Status.find_by_id(status_id) + else + self.work_package.status + end + + work_package.status = status + end +end diff --git a/app/services/scm/create_managed_repository_service.rb b/app/services/scm/create_managed_repository_service.rb new file mode 100644 index 00000000000..2a3025f64c9 --- /dev/null +++ b/app/services/scm/create_managed_repository_service.rb @@ -0,0 +1,82 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +## +# Implements the creation of a local repository. +Scm::CreateManagedRepositoryService = Struct.new :repository do + ## + # Checks if a given repository may be created and managed locally. + # Registers an job to create the repository on disk. + # + # @return True if the repository creation request has been initiated, false otherwise. + def call + if repository.managed? && repository.manageable? + + # Cowardly refusing to override existing local repository + return false if repository_exists? + + ## + # We want to move this functionality in a Delayed Job, + # but this heavily interferes with the error handling of the whole + # repository management process. + # Instead, this will be refactored into a single service wrapper for + # creating and deleting repositories, which provides transactional DB access + # as well as filesystem access. + Scm::CreateRepositoryJob.new(repository).perform + return true + end + + false + rescue Errno::EACCES + @rejected = I18n.t('repositories.errors.path_permission_failed', + path: repository.root_url) + false + rescue SystemCallError => e + @rejected = I18n.t('repositories.errors.filesystem_access_failed', + message: e.message) + false + end + + ## + # Returns the error symbol + def localized_rejected_reason + @rejected ||= I18n.t('repositories.errors.not_manageable') + end + + private + + ## + # Test if the repository exists already on filesystem. + def repository_exists? + if File.directory?(repository.root_url) + @rejected = I18n.t('repositories.errors.exists_on_filesystem') + return true + end + end +end diff --git a/spec/legacy/unit/default_data_spec.rb b/app/services/scm/delete_managed_repository_service.rb similarity index 56% rename from spec/legacy/unit/default_data_spec.rb rename to app/services/scm/delete_managed_repository_service.rb index 22631167da0..c5990bc1696 100644 --- a/spec/legacy/unit/default_data_spec.rb +++ b/app/services/scm/delete_managed_repository_service.rb @@ -26,43 +26,35 @@ # # See doc/COPYRIGHT.rdoc for more details. #++ -require 'legacy_spec_helper' -describe Redmine::DefaultData do - include Redmine::I18n +## +# Implements the asynchronous deletion of a local repository. +Scm::DeleteManagedRepositoryService = Struct.new :repository do + ## + # Checks if a given repository may be deleted + # Registers an asynchronous job to delete the repository on disk. + # + def call + if repository.managed? - before do - delete_loaded_data! - assert Redmine::DefaultData::Loader::no_data? - end + # Create necessary changes to repository to mark + # it as managed by OP, but delete asynchronously. + managed_path = repository.root_url - it 'should no_data' do - Redmine::DefaultData::Loader::load - assert !Redmine::DefaultData::Loader::no_data? - - delete_loaded_data! - assert Redmine::DefaultData::Loader::no_data? - end - - it 'should load' do - valid_languages.each do |lang| - begin - delete_loaded_data! - assert Redmine::DefaultData::Loader::load(lang) - assert_not_nil IssuePriority.first - assert_not_nil TimeEntryActivity.first - rescue ActiveRecord::RecordInvalid => e - assert false, ":#{lang} default data is invalid (#{e.message})." + if File.directory?(managed_path) + ## + # We want to move this functionality in a Delayed Job, + # but this heavily interferes with the error handling of the whole + # repository management process. + # Instead, this will be refactored into a single service wrapper for + # creating and deleting repositories, which provides transactional DB access + # as well as filesystem access. + Scm::DeleteRepositoryJob.new(managed_path).perform end + + true + else + false end end - - private - - def delete_loaded_data! - Role.delete_all('builtin = 0') - ::Type.delete_all(is_standard: false) - Status.delete_all - Enumeration.delete_all - end end diff --git a/app/services/scm/repository_factory_service.rb b/app/services/scm/repository_factory_service.rb new file mode 100644 index 00000000000..03435e67ae0 --- /dev/null +++ b/app/services/scm/repository_factory_service.rb @@ -0,0 +1,92 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +## +# Implements a repository factory for building temporary and permanent repositories. +Scm::RepositoryFactoryService = Struct.new :project, :params do + attr_reader :repository + + ## + # Build a full repository from a given scm_type + # and persists it. + # + # @return [Boolean] true iff the repository was built + def build_and_save + build_guarded do + repository = build_with_type(params.fetch(:scm_type).to_sym) + if repository.save + repository + else + raise OpenProject::Scm::Exceptions::RepositoryBuildError.new( + repository.errors.full_messages.join("\n") + ) + end + end + end + + ## + # Build a temporary repository used only for determining availabe settings and types + # of that particular vendor. + # + # @return [Boolean] true iff the repository was built + def build_temporary + build_guarded do + build_with_type(nil) + end + end + + def build_error + I18n.t('repositories.errors.build_failed', reason: @build_failed_msg) + end + + private + + ## + # Helper to actually build the repository and return it. + # May raise +OpenProject::Scm::Exceptions::RepositoryBuildError+ internally. + # + # @param [Symbol] scm_type Type to build the repository with. May be nil + # during temporary build + def build_with_type(scm_type) + Repository.build( + project, + params[:scm_vendor], + params.fetch(:repository, {}), + scm_type + ) + end + + def build_guarded + @repository = yield + @repository.present? + rescue OpenProject::Scm::Exceptions::RepositoryBuildError => e + @build_failed_msg = e.message + nil + end +end diff --git a/app/services/update_work_package_service.rb b/app/services/update_work_package_service.rb index ce2aa1d0113..7a613b115f6 100644 --- a/app/services/update_work_package_service.rb +++ b/app/services/update_work_package_service.rb @@ -35,7 +35,7 @@ class UpdateWorkPackageService self.work_package = work_package self.permitted_params = permitted_params - JournalObserver.instance.send_notification = send_notifications + JournalManager.send_notification = send_notifications end def update diff --git a/app/views/admin/projects.html.erb b/app/views/admin/projects.html.erb index ce79454bb4a..1fecda6d316 100644 --- a/app/views/admin/projects.html.erb +++ b/app/views/admin/projects.html.erb @@ -27,10 +27,6 @@ See doc/COPYRIGHT.rdoc for more details. ++#%> -
    - <%= render :partial => 'no_data' if @no_configuration_data %> -
    - <% html_title(l(:label_administration), l(:label_project_plural)) -%> <%= toolbar title: l(:label_project_plural) do %>
  • @@ -40,6 +36,31 @@ See doc/COPYRIGHT.rdoc for more details. <% end %>
  • <% end %> +
    +
    +
    +

    <%= l(:label_information) %>

    +
    +
    +
    +
    <%= l(:label_project_count) %>
    +
    +
    + + <%= l(:label_x_projects, :count => Project.count) %> + +
    +
    +
    <%= l(:label_required_disk_storage) %>
    +
    +
    + + <%= number_to_human_size(Project.total_projects_size, precision: 2) %> + +
    +
    +
    +
    <%= form_tag({}, :method => :get) do %>
    <%= l(:label_filter_plural) %> @@ -60,13 +81,13 @@ See doc/COPYRIGHT.rdoc for more details.
    <% end %>   -
    + @@ -76,6 +97,8 @@ See doc/COPYRIGHT.rdoc for more details. <%= project.css_classes %> <%= level > 0 ? "idnt idnt-#{level}" : nil %>"> + + + +<% end %> diff --git a/app/views/projects/settings/_repository.html.erb b/app/views/projects/settings/_repository.html.erb deleted file mode 100644 index 88289fecc5b..00000000000 --- a/app/views/projects/settings/_repository.html.erb +++ /dev/null @@ -1,64 +0,0 @@ -<%#-- copyright -OpenProject is a project management system. -Copyright (C) 2012-2015 the OpenProject Foundation (OPF) - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License version 3. - -OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -Copyright (C) 2006-2013 Jean-Philippe Lang -Copyright (C) 2010-2013 the ChiliProject Team - -This program is free software; you can redistribute it and/or -modify it under the terms of the GNU General Public License -as published by the Free Software Foundation; either version 2 -of the License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -See doc/COPYRIGHT.rdoc for more details. - -++#%> - -<%= remote_form_for :repository, @repository, - :url => { :controller => '/repositories', :action => 'edit', :id => @project }, - :builder => TabularFormBuilder, - :lang => current_language, - html: { class: 'form' } do |f| %> - - <%= error_messages_for 'repository' %> - -
    - <%= label_tag('repository_scm', l(:label_scm), class: "form--label") %> -
    -
    - <%= scm_select_tag(@repository) %> -
    -
    -
    - - <%= repository_field_tags(f, @repository) if @repository %> - -
    - <% if @repository && !@repository.new_record? %> - <%= link_to(l(:label_user_plural), {:controller => '/repositories', :action => 'committers', :project_id => @project}, :class => 'icon icon-user1') %> - <%= link_to(l(:button_delete), project_repository_path(@project), - data: { confirm: l(:text_are_you_sure) }, - :method => :delete, - :class => 'icon icon-delete') %> - <% end %> -
    - -
    - - <%= f.button((@repository.nil? || @repository.new_record?) ? l(:button_create) : l(:button_save), - :disabled => @repository.nil?, - class: 'button -highlight -with-icon icon-yes') %> -<% end %> diff --git a/app/views/queries/_filters.html.erb b/app/views/queries/_filters.html.erb index af2f4dfe478..f5225d87a7d 100644 --- a/app/views/queries/_filters.html.erb +++ b/app/views/queries/_filters.html.erb @@ -33,6 +33,7 @@ See doc/COPYRIGHT.rdoc for more details. select = $('add_filter_select'); field = select.value Element.show('tr_' + field); + Element.show('filter_spacer'); check_box = $('cb_' + field); check_box.checked = true; toggle_filter(field); @@ -160,7 +161,7 @@ when :list, :list_optional, :list_status, :list_subprojects %> <% end %> -
  • +
  • - + <% changeset = @project.repository.find_changeset_by_name(entry.lastrev.identifier) if entry.lastrev && entry.lastrev.identifier %> diff --git a/app/views/repositories/_navigation.html.erb b/app/views/repositories/_navigation.html.erb index 180a5d54d40..c2554a7d9d2 100644 --- a/app/views/repositories/_navigation.html.erb +++ b/app/views/repositories/_navigation.html.erb @@ -31,38 +31,26 @@ See doc/COPYRIGHT.rdoc for more details. <%= javascript_include_tag 'repository_navigation' %> <% end %> <% merge ||= {} %> -<% crumbs = render :partial => 'breadcrumbs', :locals => { :path => @path, :revision => @rev }.merge(merge) %> -<%= toolbar title: crumbs do %> +<%= toolbar title: l(:label_repository) do %> + <% if User.current.allowed_to?(:browse_repository, @project) %>
  • <%= link_to stats_project_repository_path(@project), class: 'button' do %> <%= l(:label_statistics) %> <% end %>
  • - <%# rev => nil prevents overwriting the rev parameter queried for in the form with the parameter from the request %> - <%= form_tag({:action => controller.action_name, :project_id => @project, :path => to_path_param(@path), :rev => nil}, {:method => :get, :id => 'revision_selector'}) do -%> - - <% if !@repository.branches.nil? && @repository.branches.length > 0 -%> -
  • - <%= label_tag :branch, l(:label_branch), class: 'hidden-for-sighted' %> - <%= select_tag :branch, options_for_select([''] + @repository.branches, @rev), id: 'branch' %> -
  • - <% end -%> - - <% if !@repository.tags.nil? && @repository.tags.length > 0 -%> -
  • - <%= label_tag :tag, l(:label_tag), class: 'hidden-for-sighted' %> - <%= select_tag :tag, options_for_select([''] + @repository.tags, @rev), id: 'tag' %> -
  • - <% end -%> + <% end %> + <% if User.current.allowed_to?(:manage_repository, @project) %>
  • - <%= label_tag :rev, l(:label_revision), class: 'hidden-for-sighted' %> - <%= text_field_tag :rev, @rev, placeholder: l(:label_revision) %> + <%= link_to settings_project_path(@project, tab: 'repository') , class: 'button' do %> + + <%= l(:label_settings) %> + <% end %>
  • - <% if User.current.impaired? %> -
  • - <%= button_tag 'OK', type: :submit, class: 'button -highlight' %> -
  • - <% end %> - <% end -%> + <% end %> <% end %> +
    +

    + <%= render :partial => 'breadcrumbs', :locals => { :path => @path, :revision => @rev }.merge(merge) %> +

    +
    diff --git a/app/views/repositories/_settings.html.erb b/app/views/repositories/_settings.html.erb new file mode 100644 index 00000000000..1b85da7f67a --- /dev/null +++ b/app/views/repositories/_settings.html.erb @@ -0,0 +1,67 @@ +<%#-- copyright +OpenProject is a project management system. +Copyright (C) 2012-2015 the OpenProject Foundation (OPF) + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See doc/COPYRIGHT.rdoc for more details. + +++#%> + + +<% is_creation_form = @repository.nil? || @repository.new_record? %> +<%= form_for :repository, + remote: true, + url: { controller: '/repositories', + action: is_creation_form ? 'create' : 'update', + project_id: @project }, + method: is_creation_form ? 'POST' : 'PUT', + builder: TabularFormBuilder, + html: { class: 'form' } do |f| %> + <%= error_messages_for 'repository' %> + <%# Hide the select for existing repositories %> + <% if is_creation_form %> +
    + <%= label_tag('scm_vendor', l('repositories.scm_vendor'), class: "form--label") %> +
    +
    + <%= scm_vendor_tag(@repository) %> +
    +
    +
    + <% end %> + + <%# Show (selected) type options %> + <% unless @repository.nil? %> + <%= render partial: "/repositories/settings/vendor_form", + locals: { form: f, repository: @repository, vendor: vendor_name(@repository) } %> + <% end %> + + <%# Allow plugins to add additional information %> + <%= call_hook :repository_settings_fields, repository: @repository, form: f %> + + <%# Hide the save button for existing managed repositories %> + <% if show_settings_save_button?(@repository) %> + <%= render partial: "/repositories/settings/submit", + locals: { form: f, is_creation_form: is_creation_form, project: @project } %> + <% end %> +<% end %> diff --git a/app/views/repositories/committers.html.erb b/app/views/repositories/committers.html.erb index 3e561cc2b8a..8514873dba6 100644 --- a/app/views/repositories/committers.html.erb +++ b/app/views/repositories/committers.html.erb @@ -53,6 +53,12 @@ See doc/COPYRIGHT.rdoc for more details. <% end -%>
    <%= Project.model_name.human %> <%= Project.human_attribute_name(:is_public) %><%= l(:label_required_disk_storage) %> <%= Project.human_attribute_name(:created_on) %>
    <%= link_to project, settings_project_path(project), :title => project.short_description %> <%= checked_image project.is_public? %><%= render partial: '/storage/required_storage', + locals: { storage: project.required_storage} %> <%= format_date(project.created_on) %> <%= link_to(l(:button_archive), diff --git a/app/views/api/v2/planning_element_journals/_journal.api.rabl b/app/views/api/v2/planning_element_journals/_journal.api.rabl index 3039ef0c7d0..a1f424d8112 100644 --- a/app/views/api/v2/planning_element_journals/_journal.api.rabl +++ b/app/views/api/v2/planning_element_journals/_journal.api.rabl @@ -37,13 +37,13 @@ node :user do |journal| end node :changes do |journal| - journal.changed_data.map do |attribute, changes| + journal.details.map do |attribute, details| user_friendly_attribute, old, new = user_friendly_change(journal, attribute) { technical: { name: attribute.to_s, - old: changes.first, - new: changes.last + old: details.first, + new: details.last }, user_friendly: { name: user_friendly_attribute, @@ -57,4 +57,4 @@ end node :created_on do |journal| journal.created_at.utc.iso8601 -end \ No newline at end of file +end diff --git a/app/views/auth_sources/index.html.erb b/app/views/auth_sources/index.html.erb index 9a2e21c0e31..69325664a0c 100644 --- a/app/views/auth_sources/index.html.erb +++ b/app/views/auth_sources/index.html.erb @@ -30,7 +30,7 @@ See doc/COPYRIGHT.rdoc for more details. <% html_title l(:label_administration), l(:label_auth_source_plural) %> <%= toolbar title: l(:label_auth_source_plural) do %>
  • - <%= link_to new_auth_source_path, class: 'button -alt-highlight' do %> + <%= link_to({ action: 'new' }, class: 'button -alt-highlight') do %> <%= l(:label_auth_source_new) %> <% end %> diff --git a/app/views/journals/index.atom.builder b/app/views/journals/index.atom.builder index adfef88f18d..e1aa7f08a33 100644 --- a/app/views/journals/index.atom.builder +++ b/app/views/journals/index.atom.builder @@ -47,7 +47,7 @@ xml.feed "xmlns" => "http://www.w3.org/2005/Atom" do end xml.content "type" => "html" do xml.text! '
      ' - change.changed_data.each do |detail| + change.details.each do |detail| change_content = change.render_detail(detail, :no_html => false) xml.text!(content_tag(:li, change_content)) if change_content.present? end diff --git a/app/views/layouts/angular.html.erb b/app/views/layouts/angular.html.erb index 8c065a3bebb..06fe86891b4 100644 --- a/app/views/layouts/angular.html.erb +++ b/app/views/layouts/angular.html.erb @@ -66,6 +66,7 @@ See doc/COPYRIGHT.rdoc for more details. + <% main_menu = render_main_menu(@project) %> <% side_displayed = content_for?(:sidebar) || content_for?(:main_menu) || !main_menu.blank? %>
      <%= (show_decoration) ? '' : 'nomenus' %>" diff --git a/app/views/projects/form/_project_attributes.html.erb b/app/views/projects/form/_project_attributes.html.erb index d8d06109168..aaefb06b8c9 100644 --- a/app/views/projects/form/_project_attributes.html.erb +++ b/app/views/projects/form/_project_attributes.html.erb @@ -47,3 +47,5 @@ See doc/COPYRIGHT.rdoc for more details. locals: { form: form } %> <%= render partial: "projects/form/attributes/responsible_id", locals: { form: form } %> +<%= render partial: "projects/form/attributes/storage", + locals: { form: form } %> diff --git a/app/views/projects/form/attributes/_storage.html.erb b/app/views/projects/form/attributes/_storage.html.erb new file mode 100644 index 00000000000..33682b22444 --- /dev/null +++ b/app/views/projects/form/attributes/_storage.html.erb @@ -0,0 +1,42 @@ +<%#-- copyright +OpenProject is a project management system. +Copyright (C) 2012-2015 the OpenProject Foundation (OPF) + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See doc/COPYRIGHT.rdoc for more details. + +++#%> +<% + project = form.object + storage = project.required_storage +%> + +<% if storage.key?(:total) && storage[:total] > 0 %> +
      + + +
  • <%= render partial: '/storage/required_storage', + locals: { storage: storage } %>
    - <% if entry.is_dir? %> + <% if entry.dir? %> ">  <% end %> <%= link_to h(ent_name), - {:action => (entry.is_dir? ? 'show' : 'changes'), :project_id => @project, :path => to_path_param(ent_path), :rev => @rev}, - :class => (entry.is_dir? ? 'icon-context icon-folder' : "icon icon-file #{Redmine::MimeType.css_class_of(ent_name)}")%> + {:action => (entry.dir? ? 'show' : 'changes'), :project_id => @project, :path => to_path_param(ent_path), :rev => @rev}, + :class => (entry.dir? ? 'icon-context icon-folder' : "icon icon-file #{Redmine::MimeType.css_class_of(ent_name)}")%> <%= (entry.size ? number_to_human_size(entry.size) : "?") unless entry.is_dir? %><%= (entry.size ? number_to_human_size(entry.size) : "?") unless entry.dir? %><%= link_to_revision(changeset, @project) if changeset %> <%= distance_of_time_in_words(entry.lastrev.time, Time.now) if entry.lastrev && entry.lastrev.time %>
    -

    <%= submit_tag l(:button_update), class: 'button -highlight' %>

    +
    +

    + <%= submit_tag l(:button_update), class: 'button -highlight' %> + <%= link_to settings_project_path(id: @project.id, tab: 'repository'), class: 'button' do %> + <%= l(:button_cancel) %> + <% end %> +

    <% end %> <% end %> diff --git a/app/views/repositories/destroy_info.html.erb b/app/views/repositories/destroy_info.html.erb new file mode 100644 index 00000000000..bdec0a349be --- /dev/null +++ b/app/views/repositories/destroy_info.html.erb @@ -0,0 +1,53 @@ +<%#-- copyright +OpenProject is a project management system. +Copyright (C) 2012-2015 the OpenProject Foundation (OPF) + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See doc/COPYRIGHT.rdoc for more details. + +++#%> +<%= toolbar title: l(:label_confirmation) %> +
    +
    +

    <%= l('repositories.destroy.title') %>

    +

    + <% if @repository.managed? %> + <%= l('repositories.destroy.managed_text', project_name: @project.identifier) %> +
    + <%= l('repositories.destroy.managed_path_note', path: @repository.root_url) %> + <% else %> + <%= simple_format l('repositories.destroy.linked_text', + project_name: @project.identifier, url: @repository.url) %> + <% end %> +

    +

    + <%= link_to project_repository_path(@project), + :method => :delete, + :class => 'button' do %> + + <%= l(:button_delete) %> + <% end %> +

    +
    +
    + diff --git a/app/views/projects/settings/repository.js.erb b/app/views/repositories/empty.html.erb similarity index 80% rename from app/views/projects/settings/repository.js.erb rename to app/views/repositories/empty.html.erb index b3f1146c3cb..fbe22efc276 100644 --- a/app/views/projects/settings/repository.js.erb +++ b/app/views/repositories/empty.html.erb @@ -27,11 +27,10 @@ See doc/COPYRIGHT.rdoc for more details. ++#%> -<% partial = escape_javascript render(partial: 'projects/settings/repository') %> -jQuery('#tab-content-repository').html("<%= partial %>"); - -<% if reload_menu %> - jQuery('#main-menu').html("<%= escape_javascript(render_main_menu(project)) %>"); -<% end %> - -activateFlashError(); +
    +
    +

    <%= l('repositories.errors.exception_title', + message: l('repositories.errors.empty_repository')) %>

    + <%# TODO: Add checkout instructions when included in core %> +
    +
    diff --git a/app/views/repositories/settings/_submit.html.erb b/app/views/repositories/settings/_submit.html.erb new file mode 100644 index 00000000000..d6cb884d29e --- /dev/null +++ b/app/views/repositories/settings/_submit.html.erb @@ -0,0 +1,6 @@ + diff --git a/app/views/repositories/settings/_vendor_attribute_groups.html.erb b/app/views/repositories/settings/_vendor_attribute_groups.html.erb new file mode 100644 index 00000000000..3a9e01c2555 --- /dev/null +++ b/app/views/repositories/settings/_vendor_attribute_groups.html.erb @@ -0,0 +1,32 @@ +
    +
    +
    +

    + + <%= alone ? 'checked' : '' %>> + <%= l("repositories.#{vendor}.#{type}_title") %> +

    +
    + <% unless repository.new_record? %> +
    + <%= link_to l(:label_user_plural), committers_project_repository_path(repository.project), + class: 'icon icon-user1' %> + <%= link_to l(:button_delete), destroy_info_project_repository_path(repository.project), + method: :get, + class: 'icon icon-delete' %> +
    + <% end %> +
    + +

    <%= l("repositories.#{vendor}.#{type}_introduction") %>

    +
    > + + <%= render partial: "/repositories/settings/#{vendor}/#{type}", + locals: { form: form, repository: repository } %> +
    +
    diff --git a/app/views/repositories/settings/_vendor_form.html.erb b/app/views/repositories/settings/_vendor_form.html.erb new file mode 100644 index 00000000000..1d6819f3131 --- /dev/null +++ b/app/views/repositories/settings/_vendor_form.html.erb @@ -0,0 +1,44 @@ +<%#-- copyright +OpenProject is a project management system. +Copyright (C) 2012-2015 the OpenProject Foundation (OPF) + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See doc/COPYRIGHT.rdoc for more details. + +++#%> +<% if repository.new_record? %> + <% scm_types = repository.class.available_types %> +
    + <% scm_types.each do |type|%> + <%= render partial: "/repositories/settings/vendor_attribute_groups", + locals: { existing: false, alone: scm_types.length == 1, + vendor: vendor, type: type, + form: form, repository: repository } %> + <% end %> +
    +<% else %> + <%= render partial: "/repositories/settings/vendor_attribute_groups", + locals: { existing: true, alone: true, vendor: repository.vendor.underscore, + type: repository.scm_type, + form: form, repository: repository } %> +<% end %> diff --git a/app/views/repositories/settings/git/_local.html.erb b/app/views/repositories/settings/git/_local.html.erb new file mode 100644 index 00000000000..a5bf7b53bac --- /dev/null +++ b/app/views/repositories/settings/git/_local.html.erb @@ -0,0 +1,45 @@ +<%#-- copyright +OpenProject is a project management system. +Copyright (C) 2012-2015 the OpenProject Foundation (OPF) + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See doc/COPYRIGHT.rdoc for more details. + +++#%> +
    + <%= form.text_field(:url, + label: l('repositories.git.path'), + size: 60, + required: true) %> +
    + <%= raw l('repositories.git.instructions.path', example_path: '/path/to/repository/.git') %> +
    +
    +
    + <%= form.select(:path_encoding, + git_path_encoding_options(repository), + label: l('repositories.git.path_encoding')) %> +
    + <%= l('repositories.git.instructions.path_encoding') %> +
    +
    diff --git a/app/views/repositories/settings/git/_managed.html.erb b/app/views/repositories/settings/git/_managed.html.erb new file mode 100644 index 00000000000..a55c63833e4 --- /dev/null +++ b/app/views/repositories/settings/git/_managed.html.erb @@ -0,0 +1,42 @@ +<%#-- copyright +OpenProject is a project management system. +Copyright (C) 2012-2015 the OpenProject Foundation (OPF) + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See doc/COPYRIGHT.rdoc for more details. + +++#%> +
    + <%= form.text_field(:url, + label: l('repositories.git.managed_path'), + value: repository.new_record? ? repository.managed_repository_path : + repository.root_url, + size: 60, + disabled: true, + 'aria-disabled' => true + + ) %> +
    + <%= l('repositories.git.instructions.managed_url') %> +
    +
    diff --git a/app/views/repositories/settings/repository_form.js.erb b/app/views/repositories/settings/repository_form.js.erb new file mode 100644 index 00000000000..a9de0355611 --- /dev/null +++ b/app/views/repositories/settings/repository_form.js.erb @@ -0,0 +1,68 @@ +(function($) { + + <% content = render partial: 'repositories/settings' %> + $('#tab-content-repository').html('<%= escape_javascript content %>'); + + <% unless flash.empty? %> + <%# TODO: Double flash from regular flash %> + var div_content = $('#content'); + div_content.find('.flash').remove(); + div_content.prepend('<%= render_flash_messages %>'); + <% end %> + + var toggleContent = function(content, selected) { + var targetName = '#toggleable-attributes-group--content-' + selected, + oldTargets = content.not(targetName), + newTarget = $(targetName); + + // would work with fieldset#disabled, but that's bugged up unto IE11 + // https://connect.microsoft.com/IE/feedbackdetail/view/962368/ + // + // Ugly workaround: disable all inputs manually, but + // spare enabling inputs marked with `aria-disabled` + oldTargets + .slideUp(500) + .prop('hidden', true) + .find('input,select') + .prop('disabled', true); + + newTarget + .slideDown(500) + .prop('hidden', false) + .find('input,select') + .not('[aria-disabled="true"]') + .prop('disabled', false); + }; + + $('#tab-content-repository') + .find('.attributes-group.-toggleable') + .each(function(_i, el) { + + var fs = $(el), + name = fs.attr('data-switch'), + switches = fs.find('[name="' + name + '"]'), + headers = fs.find('.attributes-group--header-text'), + content = fs.find('.attributes-group--content.-toggleable'); + + // If only one choice, skip setup + if (switches.length === 1) { + return; + } + + // Clickable headers + headers.click(function() { + var input = $(this).find('input'); + if (!input.prop('checked')) { + input + .prop('checked', true) + .trigger('change'); + } + }); + + // Toggle content + switches.on('change', function() { + toggleContent(content, this.value); + }); + }); + +}(jQuery)); diff --git a/app/views/repositories/settings/subversion/_existing.html.erb b/app/views/repositories/settings/subversion/_existing.html.erb new file mode 100644 index 00000000000..cfe6b326691 --- /dev/null +++ b/app/views/repositories/settings/subversion/_existing.html.erb @@ -0,0 +1,63 @@ +<%#-- copyright +OpenProject is a project management system. +Copyright (C) 2012-2015 the OpenProject Foundation (OPF) + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See doc/COPYRIGHT.rdoc for more details. + +++#%> +
    + <%= form.text_field(:url, + label: l('repositories.url'), + required: true, + size: 60, + disabled: !repository.new_record? + ) %> +
    + <%= raw simple_format l('repositories.subversion.instructions.url', local_proto: "file://") %> + file:// + http:// + https:// + svn:// + svn+[tunnelscheme]:// +
    +
    +
    +
    +
    + <%= form.text_field(:login, + size: 30, + label: l('repositories.subversion.username')) %> +
    +
    +
    +
    + <%= form.password_field(:password, + size: 30, + label: l('repositories.subversion.password'), + name: 'ignore', + value: ((repository.new_record? || repository.password.blank?) ? '' : ('x' * 15)), + onfocus: "this.value=''; this.name='repository[password]';", + onchange: "this.name='repository[password]';") + %> +
    diff --git a/app/views/repositories/settings/subversion/_managed.html.erb b/app/views/repositories/settings/subversion/_managed.html.erb new file mode 100644 index 00000000000..ad3448f1a3c --- /dev/null +++ b/app/views/repositories/settings/subversion/_managed.html.erb @@ -0,0 +1,41 @@ +<%#-- copyright +OpenProject is a project management system. +Copyright (C) 2012-2015 the OpenProject Foundation (OPF) + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See doc/COPYRIGHT.rdoc for more details. + +++#%> +
    + <%= form.text_field(:url, + label: l('repositories.subversion.managed_url'), + value: repository.new_record? ? repository.managed_repository_url : + repository.url, + size: 60, + disabled: true, + 'aria-disabled' => true + ) %> +
    + <%= l('repositories.subversion.instructions.managed_url') %> +
    +
    diff --git a/app/views/repositories/show.html.erb b/app/views/repositories/show.html.erb index f4ab1246496..f2ac9ba776c 100644 --- a/app/views/repositories/show.html.erb +++ b/app/views/repositories/show.html.erb @@ -29,6 +29,7 @@ See doc/COPYRIGHT.rdoc for more details. <%= call_hook(:view_repositories_show_contextual, { :repository => @repository, :project => @project }) %> + <%= render :partial => 'navigation', locals: { merge: { kind: 'dir' } } %> <% if !@entries.nil? && authorize_for('repositories', 'browse') %> @@ -37,9 +38,63 @@ See doc/COPYRIGHT.rdoc for more details. <%= render_properties(@properties) %> +<%# rev => nil prevents overwriting the rev parameter queried for in the form with the parameter from the request %> +<%= form_tag({ action: controller.action_name, + project_id: @project, + path: to_path_param(@path), + rev: nil + }, + method: :get, + id: 'revision_selector', + class: 'form -vertical' + ) do %> +
    + <%= l(:label_revision_plural) %> +
    +
    +
    + +
    +
    + <%= text_field_tag :rev, @rev, id: 'revision-identifier-input', placeholder: l(:label_revision) %> +
    +
    +
    +
    + <% if !@repository.branches.nil? && @repository.branches.length > 0 %> +
    +
    + +
    +
    + <%= select_tag :branch, options_for_select([''] + @repository.branches, @rev), id: 'revision-branch-select' %> +
    +
    +
    +
    + <% end %> + <% if !@repository.tags.nil? && @repository.tags.length > 0 %> +
    +
    + +
    +
    + <%= select_tag :tag, options_for_select([''] + @repository.tags, @rev), id: 'revision-tag-select' %> +
    +
    +
    +
    + <% end %> +
    + <% if User.current.impaired? %> +
    + <%= button_tag 'OK', type: :submit, class: 'button -highlight' %> +
    + <% end %> +
    +<% end %> <% if authorize_for('repositories', 'revisions') %> <% if @changesets && !@changesets.empty? %> -

    <%= l(:label_latest_revision_plural) %>

    <%= render :partial => 'revisions', :locals => {:project => @project, :path => @path, :revisions => @changesets, :entry => nil }%> diff --git a/app/views/search/index.html.erb b/app/views/search/index.html.erb index 07759874790..70e7daad6f1 100644 --- a/app/views/search/index.html.erb +++ b/app/views/search/index.html.erb @@ -47,20 +47,29 @@ See doc/COPYRIGHT.rdoc for more details.
  • - <%= styled_label_tag :all_words, class: 'form--label-with-check-box' do %> - <%= styled_check_box_tag 'all_words', 1, @all_words %> <%= l(:label_all_words) %> - <% end %> - <%= styled_label_tag :titles_only, class: 'form--label-with-check-box' do %> - <%= styled_check_box_tag 'titles_only', 1, @titles_only %> <%= l(:label_search_titles_only) %> - <% end %> + + <%= styled_label_tag :all_words, l(:label_all_words) %> + + <%= styled_check_box_tag 'all_words', 1, @all_words %> + + + + <%= styled_label_tag :titles_only, l(:label_search_titles_only) %> + + <%= styled_check_box_tag 'titles_only', 1, @titles_only %> + +
  • - <% @object_types.each do |t| %> - <%= styled_label_tag t, class: 'form--label-with-check-box' do %> - <%= styled_check_box_tag t, 1, @scope.include?(t) %> <%= type_label(t) %> + <% @object_types.each do |t| %> + + <%= styled_label_tag t, type_label(t) %> + + <%= styled_check_box_tag t, 1, @scope.include?(t) %> + + <% end %> - <% end %>
  • <%= styled_submit_tag l(:button_submit), :name => 'submit', class: 'button -highlight -small' %> diff --git a/app/views/settings/_repositories.html.erb b/app/views/settings/_repositories.html.erb index d16c78f2038..65275ad3785 100644 --- a/app/views/settings/_repositories.html.erb +++ b/app/views/settings/_repositories.html.erb @@ -43,7 +43,7 @@ See doc/COPYRIGHT.rdoc for more details. <%= link_to_function l(:label_generate_key), "if ($('settings_sys_api_key').disabled == false) { $('settings_sys_api_key').value = randomKey(20) }" %> -
    <%= setting_multiselect(:enabled_scm, Redmine::Scm::Base.configured) %>
    +
    <%= setting_multiselect(:enabled_scm, OpenProject::Scm::Manager.vendors) %>
    <%= setting_text_field :repositories_encodings, :size => 60 %>
    <%= l(:text_comma_separated) %>
    diff --git a/app/views/storage/_required_storage.html.erb b/app/views/storage/_required_storage.html.erb new file mode 100644 index 00000000000..f00d0e18e98 --- /dev/null +++ b/app/views/storage/_required_storage.html.erb @@ -0,0 +1,41 @@ +<%#-- copyright +OpenProject is a project management system. +Copyright (C) 2012-2015 the OpenProject Foundation (OPF) + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See doc/COPYRIGHT.rdoc for more details. + +++#%> +<% if storage.key?(:total) && storage[:total] > 0 %> + + <%= number_to_human_size(storage[:total], precision: 2) %> + +<% end %> diff --git a/app/views/user_mailer/work_package_added.html.erb b/app/views/user_mailer/work_package_added.html.erb index a06b6821270..f9073c4e941 100644 --- a/app/views/user_mailer/work_package_added.html.erb +++ b/app/views/user_mailer/work_package_added.html.erb @@ -28,5 +28,6 @@ See doc/COPYRIGHT.rdoc for more details. ++#%> <%= t(:text_work_package_added, :id => "##{@issue.id}", :author => @issue.author) %> +<%= format_text(@journal.notes, :only_path => false) %>
    <%= render :partial => 'issue_details', :locals => { :issue => @issue } %> diff --git a/app/views/user_mailer/work_package_added.text.erb b/app/views/user_mailer/work_package_added.text.erb index 6caa89f7af5..456f747da16 100644 --- a/app/views/user_mailer/work_package_added.text.erb +++ b/app/views/user_mailer/work_package_added.text.erb @@ -29,5 +29,6 @@ See doc/COPYRIGHT.rdoc for more details. <%= t(:text_work_package_added, :id => "##{@issue.id}", :author => @issue.author) %> +<%= @journal.notes if @journal.notes? %> ---------------------------------------- <%= render :partial => 'issue_details', :locals => { :issue => @issue } %> diff --git a/app/views/work_packages/bulk/edit.html.erb b/app/views/work_packages/bulk/edit.html.erb index 7fb8c37dfd3..378aa9f3638 100644 --- a/app/views/work_packages/bulk/edit.html.erb +++ b/app/views/work_packages/bulk/edit.html.erb @@ -53,7 +53,7 @@ See doc/COPYRIGHT.rdoc for more details.
    - <%= styled_select_tag('work_package[priority_id]', "".html_safe + options_from_collection_for_select(IssuePriority.all, :id, :name)) %> + <%= styled_select_tag('work_package[priority_id]', "".html_safe + options_from_collection_for_select(IssuePriority.active, :id, :name)) %>
    diff --git a/app/views/work_packages/calendars/index.html.erb b/app/views/work_packages/calendars/index.html.erb index 35937ff9a5c..923fc9e4039 100644 --- a/app/views/work_packages/calendars/index.html.erb +++ b/app/views/work_packages/calendars/index.html.erb @@ -29,9 +29,9 @@ See doc/COPYRIGHT.rdoc for more details. <%= toolbar title: (@query.new_record? ? l(:label_calendar) : h(@query.name)) %> <%= form_tag(work_packages_calendar_index_path, :method => :get, :id => 'query_form') do %> <%= hidden_field_tag('project_id', @project.to_param) if @project%> -
    "> + diff --git a/app/views/work_packages/moves/new.html.erb b/app/views/work_packages/moves/new.html.erb index 597a0ef3415..3341639ecfc 100644 --- a/app/views/work_packages/moves/new.html.erb +++ b/app/views/work_packages/moves/new.html.erb @@ -72,7 +72,7 @@ See doc/COPYRIGHT.rdoc for more details.
    <%= styled_select_tag('priority_id', content_tag('option', l(:label_no_change_option), :value => '') + - options_from_collection_for_select(IssuePriority.all, :id, :name)) %> + options_from_collection_for_select(IssuePriority.active, :id, :name)) %>
    diff --git a/app/workers/mail_notification_job.rb b/app/workers/deliver_work_package_notification_job.rb similarity index 51% rename from app/workers/mail_notification_job.rb rename to app/workers/deliver_work_package_notification_job.rb index 19a7710ad95..659c3061bff 100644 --- a/app/workers/mail_notification_job.rb +++ b/app/workers/deliver_work_package_notification_job.rb @@ -27,53 +27,54 @@ # See doc/COPYRIGHT.rdoc for more details. #++ -## -# Requires including class to implement #notification_mail. -class MailNotificationJob - mattr_accessor :raise_exceptions - - def initialize(recipient_id, author_id) - @recipient_id = recipient_id - @author_id = author_id +class DeliverWorkPackageNotificationJob + def initialize(journal_id, author_id) + @journal_id = journal_id + @author_id = author_id end def perform - execute_as recipient do - notify + return unless raw_journal # abort, assuming that the underlying WP was deleted + + journal = find_aggregated_journal + + # The caller should have ensured that the journal can't outdate anymore + # before queuing a notification + raise 'aggregated journal got outdated' unless journal + + notification_receivers(work_package).uniq.each do |recipient| + mail = User.execute_as(recipient) { + if journal.initial? + UserMailer.work_package_added(recipient, journal, author) + else + UserMailer.work_package_updated(recipient, journal, author) + end + } + + mail.deliver end - rescue ActiveRecord::RecordNotFound => e - # Expecting this error if recipient user was deleted intermittently. - # Since we cannot recover from this error we catch it and move on. - Rails.logger.error "Cannot deliver notification (#{inspect}) - as required record was not found: #{e}".squish - raise e if raise_exceptions - end - - def error(_job, e) - Rails.logger.error "notification failed (#{inspect}): #{e}" - end - - protected - - def recipient - @recipient ||= Principal.find(@recipient_id) - end - - def author - @author ||= Principal.find(@author_id) end private - def notify - notification_mail.deliver + def find_aggregated_journal + wp_journals = Journal::AggregatedJournal.aggregated_journals(journable: work_package) + wp_journals.detect { |journal| journal.version == raw_journal.version } end - def execute_as(user) - previous_user = User.current - User.current = user - yield - ensure - User.current = previous_user + def notification_receivers(work_package) + work_package.recipients + work_package.watcher_recipients + end + + def raw_journal + @raw_journal ||= Journal.find_by_id(@journal_id) + end + + def work_package + @work_package ||= raw_journal.journable + end + + def author + @author ||= User.find_by_id(@author_id) || DeletedUser.first end end diff --git a/app/workers/enqueue_work_package_notification_job.rb b/app/workers/enqueue_work_package_notification_job.rb new file mode 100644 index 00000000000..f6fc101d52f --- /dev/null +++ b/app/workers/enqueue_work_package_notification_job.rb @@ -0,0 +1,75 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +# Enqueues +class EnqueueWorkPackageNotificationJob + def initialize(journal_id, author_id) + @journal_id = journal_id + @author_id = author_id + end + + def perform + # if the WP has been deleted the unaggregated journal will have been deleted too + # and our job here is done + return nil unless raw_journal + + journal = find_aggregated_journal + + # If we can't find the aggregated journal, it was superseded by a journal that aggregated ours. + # In that case a job for the new journal will have been enqueued that is now responsible for + # sending the notification. Our job here is done. + return nil unless journal + + # Do not deliver notifications if a follow-up journal will already have sent a notification + # on behalf of this job. + unless Journal::AggregatedJournal.hides_notifications?(journal.successor, journal) + deliver_notifications_for(journal) + end + end + + private + + def find_aggregated_journal + wp_journals = Journal::AggregatedJournal.aggregated_journals(journable: work_package) + wp_journals.detect { |journal| journal.version == raw_journal.version } + end + + def deliver_notifications_for(journal) + job = DeliverWorkPackageNotificationJob.new(journal.id, @author_id) + Delayed::Job.enqueue job + end + + def raw_journal + @raw_journal ||= Journal.find_by_id(@journal_id) + end + + def work_package + @work_package ||= raw_journal.journable + end +end diff --git a/app/workers/scm/create_repository_job.rb b/app/workers/scm/create_repository_job.rb new file mode 100644 index 00000000000..fc8815ccb7a --- /dev/null +++ b/app/workers/scm/create_repository_job.rb @@ -0,0 +1,91 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +## +# Provides an asynchronous job to create a managed repository on the filesystem. +# Currently, this is run synchronously due to potential issues +# with error handling. +# We envision a repository management wrapper that covers transactional +# creation and deletion of repositories BOTH on the database and filesystem. +# Until then, a synchronous process is more failsafe. +class Scm::CreateRepositoryJob + def initialize(repository) + @repository = repository + end + + def perform + # Create the repository locally. + mode = config[:mode] || default_mode + create(mode) + + # Allow adapter to act upon the created repository + # e.g., initializing an empty scm repository within it + repository.managed_repo_created + + # Ensure group ownership after creation + ensure_group(mode, config[:group]) + end + + def destroy_failed_jobs? + true + end + + private + + ## + # Creates the repository at the +root_url+. + # Accepts an overriden permission mode mask from the scm config, + # or sets a sensible default of 0700. + def create(mode) + FileUtils.mkdir_p(repository.root_url, mode: mode) + end + + ## + # Overrides the group permission of the created repository + # after the adapter was able to work in the directory. + def ensure_group(mode, group) + FileUtils.chmod_R(mode, repository.root_url) + + # Note that this is effectively a noop when group is nil, + # and then permissions remain OPs runuser/-group + FileUtils.chown_R(nil, group, repository.root_url) + end + + def config + @config ||= repository.class.scm_config + end + + def repository + @repository + end + + def default_mode + 0700 + end +end diff --git a/app/workers/scm/delete_repository_job.rb b/app/workers/scm/delete_repository_job.rb new file mode 100644 index 00000000000..7a4af1f96eb --- /dev/null +++ b/app/workers/scm/delete_repository_job.rb @@ -0,0 +1,50 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +## +# Provides an asynchronous job to delete a managed repository on the filesystem. +# Currently, this is run synchronously due to potential issues +# with error handling. +# We envision a repository management wrapper that covers transactional +# creation and deletion of repositories BOTH on the database and filesystem. +# Until then, a synchronous process is more failsafe. +class Scm::DeleteRepositoryJob + def initialize(managed_path) + @managed_path = managed_path + end + + def perform + # Delete the repository project itself. + FileUtils.remove_dir(@managed_path) + end + + def destroy_failed_jobs? + true + end +end diff --git a/app/workers/scm/storage_updater_job.rb b/app/workers/scm/storage_updater_job.rb new file mode 100644 index 00000000000..29780c61da8 --- /dev/null +++ b/app/workers/scm/storage_updater_job.rb @@ -0,0 +1,59 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +class Scm::StorageUpdaterJob + def initialize(repository) + @id = repository.id + + unless repository.scm.storage_available? + raise OpenProject::Scm::Exceptions::ScmError.new( + I18n.t('repositories.storage.not_available') + ) + end + end + + def perform + bytes = repository.scm.count_repository! + + repository.update_attributes!( + required_storage_bytes: bytes, + storage_updated_at: Time.now, + ) + end + + def destroy_failed_jobs? + true + end + + private + + def repository + @repository ||= Repository.find @id + end +end diff --git a/config/application.rb b/config/application.rb index cd4ca87a399..797a9f6637f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -96,9 +96,10 @@ module OpenProject # Activate observers that should always be running. # config.active_record.observers = :cacher, :garbage_collector, :forum_observer - config.active_record.observers = :journal_observer, :message_observer, - :news_observer, :wiki_content_observer, - :comment_observer, :work_package_observer + config.active_record.observers = :message_observer, + :news_observer, + :wiki_content_observer, + :comment_observer # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. diff --git a/config/configuration.yml.example b/config/configuration.yml.example index 05a7c361d41..279204cbc45 100644 --- a/config/configuration.yml.example +++ b/config/configuration.yml.example @@ -210,34 +210,52 @@ default: # airbrake: # api_key: # host: errbit.example.org - # post: 443 + # port: 443 (default) - # Configuration of SCM executable command. - # Absolute path (e.g. /usr/local/bin/hg) or command name (e.g. hg.exe, bzr.exe) - # On Windows, *.cmd, *.bat (e.g. hg.cmd, bzr.bat) does not work. - # Examples: - # scm_subversion_command: svn # (default: svn) - # scm_git_command: /usr/local/bin/git # (default: git) - # scm_subversion_command: - # scm_git_command: + # Configuration of Source control vendors + # client_command: + # Use this command to the default SCM vendor command (taken from path). + # Absolute path (e.g. /usr/local/bin/hg) or command name (e.g. hg.exe, bzr.exe) + # On Windows, *.cmd, *.bat (e.g. hg.cmd, bzr.bat) does not work. + # manages: + # Enable managed repositories for this vendor. This allows OpenProject to + # take control over the given path to create and delete repositories directly + # when created in the frontend. + # NOTE: Disabling :managed repositories using disabled_types takes precedence over this setting. + # mode: + # The file mode to set the repository folder to (defaults to 0700). # - # Enable filesystem base SCM by specifying the directories that are available - # to serve as an SCM directory. The format in which the available directories - # can be specified is close to a shell glob. Please see - # http://ruby-doc.org/core-2.2.0/Dir.html#method-c-glob for details. + # group: + # The group that should own the repository folder. + # Important: The user running OpenProject must be a member of this group in order to have the + # privilege to set the group ownership. + # Note: The owner is always the user that runs OpenProject! + # disabled_types: + # Disable specific repository types for this particular vendor. This allows + # to restrict the available choices a project administrator has for creating repositories + # See the example below for available types # - # Multiple patterns can be configured. + # Available types for git: + # - :local (Local repositories, registered using a local path) + # - :managed (Managed repositores, available IF :manages path is set below) + # Available types for subversion: + # - :existing (Existing subversion repositories by URL - local using file:/// or remote + # using one of the supported URL schemes (e.g., https://, svn+ssh:// ) + # - :managed (Managed repositores, available IF :manages path is set below) # - # If no directory is specified, there will be no option to configure a - # filesystem based SCM within OpenProject. + # Examplary configuration # - # Please note that this allows users to browse the filesystem which could - # lead to sensitive information (e.g. db credentials) being made accessible - # to them. - # - # scm_filesystem_path_whitelist: - # - "/opt/app/openproject/repositories/*" - # - "/tmp/**/*" + # scm: + # Git: + # client_command: /usr/local/bin/git + # disabled_types: + # - :local + # manages: /opt/repositories/git + # Subversion: + # client_command: /usr/local/bin/svn + # disabled_types: + # - :existing + # manages: /opt/repositories/svn # Key used to encrypt sensitive data in the database (SCM and LDAP passwords). # If you don't want to enable data encryption, just leave it blank. diff --git a/config/initializers/airbrake.rb b/config/initializers/airbrake.rb index fd60911368d..92f1fed2330 100644 --- a/config/initializers/airbrake.rb +++ b/config/initializers/airbrake.rb @@ -29,6 +29,9 @@ airbrake = OpenProject::Configuration['airbrake'] if airbrake && airbrake['api_key'] + # airbrake isn't loaded by default, so let's do that now + require 'airbrake' + Airbrake.configure do |config| config.api_key = airbrake['api_key'] config.host = airbrake['host'] if airbrake['host'] diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index a1744c89038..30578d598e2 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -268,7 +268,8 @@ Redmine::AccessControl.map do |map| map.project_module :repository do |repo| repo.permission :manage_repository, - { repositories: [:edit, :committers, :destroy] }, + { repositories: [:edit, :create, :update, :committers, + :destroy_info, :destroy] }, require: :member repo.permission :browse_repository, diff --git a/config/initializers/scm.rb b/config/initializers/scm.rb index db2245ed180..0f393a8e777 100644 --- a/config/initializers/scm.rb +++ b/config/initializers/scm.rb @@ -27,8 +27,7 @@ # See doc/COPYRIGHT.rdoc for more details. #++ -require 'redmine/scm/base' +require 'open_project/scm/manager' -Redmine::Scm::Base.add 'Subversion' -Redmine::Scm::Base.add 'Git' -Redmine::Scm::Base.add 'Filesystem' +OpenProject::Scm::Manager.add 'Subversion' +OpenProject::Scm::Manager.add 'Git' diff --git a/config/initializers/subscribe_listeners.rb b/config/initializers/subscribe_listeners.rb new file mode 100644 index 00000000000..d60b8fee8b0 --- /dev/null +++ b/config/initializers/subscribe_listeners.rb @@ -0,0 +1,32 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +OpenProject::Notifications.subscribe('journal_created') do |payload| + JournalNotificationMailer.distinguish_journals(payload[:journal], payload[:send_notification]) +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 3ec76b850f7..48cbc77585d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -72,6 +72,8 @@ en: host: "Host" onthefly: "On-the-fly user creation" port: "Port" + changeset: + repository: "Repository" comment: commented: "Commented" # an object that this comment belongs to custom_field: @@ -479,44 +481,65 @@ en: one: "hour" other: "hours" - default_activity_management: "Management" - default_activity_design: "Design" default_activity_development: "Development" + default_activity_management: "Management" + default_activity_other: "Other" + default_activity_specification: "Specification" + default_activity_support: "Support" default_activity_testing: "Testing" - default_activity_Support: "Support" - default_activity_Other: "Other" - default_status_new: "new" - default_status_specified: "specified" - default_status_confirmed: "confirmed" - default_status_to_be_scheduled: "to be scheduled" - default_status_scheduled: "scheduled" - default_status_in_progress: "in progress" - default_status_tested: "tested" - default_status_on_hold: "on hold" - default_status_rejected: "rejected" - default_status_closed: "closed" + default_color_black: "Black" + default_color_blue: "Blue" + default_color_blue_dark: "Blue (dark)" + default_color_blue_light: "Blue (light)" + default_color_green_dark: "Green (dark)" + default_color_green_light: "Green (light)" + default_color_grey_dark: "Grey (dark)" + default_color_grey_light: "Grey (light)" + default_color_grey: "Grey" + default_color_magenta: "Magenta" + default_color_orange: "Orange" + default_color_red: "Red" + default_color_white: "White" + default_color_yellow: "Yellow" + default_status_closed: "Closed" + default_status_confirmed: "Confirmed" + default_status_developed: "Developed" + default_status_in_development: "In development" + default_status_in_progress: "In progress" + default_status_in_specification: "In specification" + default_status_in_testing: "In testing" + default_status_new: "New" + default_status_on_hold: "On hold" + default_status_rejected: "Rejected" + default_status_scheduled: "Scheduled" + default_status_specified: "Specified" + default_status_tested: "Tested" + default_status_test_failed: "Test failed" + default_status_to_be_scheduled: "To be scheduled" default_priority_low: "Low" default_priority_normal: "Normal" default_priority_high: "High" default_priority_immediate: "Immediate" - default_project_type_customer: "Customer Project" - default_project_type_internal: "Internal Project" + default_project_type_scrum: "Scrum team" + default_project_type_standard: "Standard project" default_reported_project_status_green: "Green" default_reported_project_status_amber: "Amber" default_reported_project_status_red: "Red" default_role_anonymous: "Anonymous" default_role_developer: "Developer" - default_role_manager: "Manager" + default_role_project_admin: "Project admin" default_role_non_member: "Non member" default_role_reader: "Reader" default_role_member: "Member" default_type: "Work Package" - default_type_task: "Task" + default_type_bug: "Bug" default_type_deliverable: "Deliverable" + default_type_epic: "Epic" + default_type_feature: "Feature" default_type_milestone: "Milestone" default_type_phase: "Phase" - default_type_bug: "Bug" - default_type_feature: "Feature" + default_type_task: "Task" + default_type_user_story: "User story" description_active: "Active?" description_attachment_toggle: "Show/Hide attachments" @@ -568,7 +591,6 @@ en: error_can_not_delete_standard_type: "Standard types cannot be deleted." error_can_not_remove_role: "This role is in use and cannot be deleted." error_can_not_reopen_work_package_on_closed_version: "A work package assigned to a closed version cannot be reopened" - error_can_t_load_default_data: "Default configuration could not be loaded: %{value}" error_check_user_and_role: "Please choose a user and a role." error_work_package_done_ratios_not_updated: "Work package done ratios not updated." error_work_package_not_found_in_project: "The work package was not found or does not belong to this project" @@ -668,6 +690,7 @@ en: label_category: "Category" label_wiki_menu_item: Wiki menu item label_select_main_menu_item: Select new main menu item + label_required_disk_storage: "Required disk storage" label_change_plural: "Changes" label_change_properties: "Change properties" label_change_status: "Change status" @@ -742,7 +765,6 @@ en: label_feeds_access_key_created_on: "RSS access key created %{value} ago" label_file_added: "File added" label_file_plural: "Files" - label_filesystem_path: "Root directory" label_filter_add: "Add filter" label_filter_plural: "Filters" label_float: "Float" @@ -885,6 +907,7 @@ en: label_principal_search: "Search for users or groups" label_profile: "Profile" label_project_all: "All Projects" + label_project_count: "Total number of projects" label_project_copy_notifications: "Send email notifications during the project copy" label_project_latest: "Latest projects" label_project_default_type: "Allow empty type" @@ -1073,6 +1096,11 @@ en: label_keyboard_shortcut_focus_next_item: "Focus next list element (on some lists only)" label_visible_elements: Visible elements + auth_source: + using_abstract_auth_source: "Can't use an abstract authentication source." + ldap_error: "LDAP-Error: %{error_message}" + ldap_auth_failed: "Could not authenticate at the LDAP-Server." + macro_execution_error: "Error executing the macro %{macro_name}" macro_unavailable: "Macro %{macro_name} cannot be displayed." @@ -1113,7 +1141,6 @@ en: notice_account_registered_and_logged_in: "Welcome, your account has been activated. You are logged in now." notice_api_access_key_reseted: "Your API access key was reset." notice_can_t_change_password: "This account uses an external authentication source. Impossible to change the password." - notice_default_data_loaded: "Default configuration successfully loaded." notice_email_error: "An error occurred while sending mail (%{value})" notice_email_sent: "An email was sent to %{value}" notice_failed_to_save_work_packages: "Failed to save %{count} work package(s) on %{total} selected: %{ids}." @@ -1247,6 +1274,63 @@ en: member_of_group: "Assignee's group" subproject_id: "Subproject" + repositories: + checkout_instructions: "Checkout instructions" + create_managed_delay: "Please note: The repository is managed, it is created asynchronously on the disk and will be available shortly." + create_successful: "The repository has been registered." + delete_sucessful: "The repository has been deleted." + destroy: + title: "Are you absolutely sure?" + managed_text: "If you continue, this will permanently delete the managed repository of %{project_name}. This action cannot be reverted." + managed_path_note: "The following directory will be erased: %{path}" + linked_text: "You're about to remove the linked repository %{url} from the project %{project_name}.\nNote: This will NOT delete the contents of this repository, as it is not managed by OpenProject." + errors: + build_failed: "Unable to create the repository with the selected configuration. %{reason}" + empty_repository: "The repository exists, but is empty. It does not contain any revisions yet." + exists_on_filesystem: "The repository directory exists already on filesystem." + filesystem_access_failed: "An error occurred while creating the repository on filesystem: %{message}." + not_manageable: "This repository vendor cannot be managed by OpenProject." + path_permission_failed: "An error occurred trying to create the following path: %{path}. Please ensure that OpenProject may write to that folder." + unauthorized: "You're not authorized to access the repository or the credentials are invalid." + unavailable: "The repository is unavailable." + exception_title: "Cannot access the repository: %{message}" + disabled_or_unknown_type: "The selected type %{type} is disabled or no longer available for the SCM vendor %{vendor}." + disabled_or_unknown_vendor: "The SCM vendor %{vendor} is disabled or no longer available." + git: + instructions: + managed_url: "This is the URL of the managed (local) Git repository." + path: "Specify the path to your local Git repository ( e.g., %{example_path} )." + path_encoding: "Override Git path encoding (Default: UTF-8)" + local_title: "Link existing local Git repository" + local_introduction: "If you have an existing local Git repository, you can link it with OpenProject to access it from within the application." + managed_path: "Managed Git path (preview)" + managed_introduction: "Let OpenProject create and integrate a local Git repository automatically." + managed_title: "Git repository integrated into OpenProject" + path: "Path to Git repository" + path_encoding: "Path encoding" + go_to_revision: "Go to revision" + scm_vendor: "Source control management system" + scm_type: "Repository type" + scm_types: + local: "Link existing local repository" + existing: "Link existing repository" + managed: "Create new repository in OpenProject" + storage: + not_available: "Disk storage consumption is not available for this repository." + subversion: + existing_title: "Existing Subversion repository" + existing_introduction: "If you have an existing Subversion repository, you can link it with OpenProject to access it from within the application." + instructions: + managed_url: "This is the URL of the managed (local) Subversion repository." + url: "Enter the repository URL. This may either target a local repository (starting with %{local_proto} ), or a remote repository.\nThe following URL schemes are supported:" + managed_title: "Subversion repository integrated into OpenProject" + managed_introduction: "Let OpenProject create and integrate a local Subversion repository automatically." + managed_url: "Managed URL" + password: "Repository Password" + username: "Repository username" + update_settings_successful: "The settings have been sucessfully saved." + url: "URL to repository" + search_input_placeholder: "search ..." setting_activity_days_default: "Days displayed on project activity" diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 356ef79c9df..e3ca9e275e7 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -84,6 +84,7 @@ en: label_collapse_all: "Collapse all" label_commented_on: "commented on" label_contains: "contains" + label_created_on: "created on" label_equals: "is" label_expand: "Expand" label_expanded: "expanded" @@ -152,6 +153,20 @@ en: label_custom_queries: "Private queries" label_columns: "Columns" label_click_to_enter_description: "Click to enter description..." + label_attachments: Files + label_drop_files: Drop files here + label_drop_files_hint: or click to add files + label_remove_file: Delete + label_remove_all_files: Delete all files + label_add_description: "Add a description for %{file}" + label_upload_notification: "Uploading files for Work package #%{id}: %{subject}" + label_files_to_upload: "These files will be uploaded:" + label_rejected_files: "These files cannot be uploaded:" + label_rejected_files_reason: "These files cannot be uploaded as their size is greater than %{maximumFilesize}" + label_wait: "Please wait for configuration..." + label_upload_counter: "%{done} of %{count} files finished" + label_successful_update: 'Successful update' + text_are_you_sure: "Are you sure?" diff --git a/config/routes.rb b/config/routes.rb index 6f8949b4ae5..b8dcdd7474a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -329,9 +329,11 @@ OpenProject::Application.routes.draw do match :autocomplete_for_member, on: :collection, via: [:get, :post] end - resource :repository, only: [:destroy] do + resource :repository, controller: 'repositories', except: [:new] do get :edit # needed as show is configured manually with a wildcard - post :edit + + # Destroy uses a get request to prompt the user before the actual DELETE request + get :destroy_info get :committers post :committers get :graph @@ -512,6 +514,7 @@ OpenProject::Application.routes.draw do scope controller: 'sys' do match '/sys/projects.:format', action: 'projects', via: :get + match '/sys/projects/:id/repository/update_storage', action: 'update_required_storage', via: :get match '/sys/projects/:id/repository.:format', action: 'create_project_repository', via: :post end diff --git a/config/settings.yml b/config/settings.yml index 4d3679d6eb1..8885c4c6669 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -103,6 +103,8 @@ available_languages: serialized: true default: - en + - de + - fr default_language: default: en host_name: @@ -171,6 +173,15 @@ notified_events: default: - work_package_added - work_package_updated + - work_package_note_added + - status_updated + - work_package_priority_updated + - news_added + - news_comment_added + - file_added + - message_posted + - wiki_content_added + - wiki_content_updated mail_handler_body_delimiters: default: '' mail_handler_api_enabled: @@ -264,3 +275,9 @@ users_deletable_by_self: journal_aggregation_time_minutes: default: 5 format: int +repository_storage_cache_minutes: + default: 720 + format: int +project_storage_cache_minutes: + default: 60 + format: int diff --git a/db/migrate/20150629075221_add_scm_type_to_repositories.rb b/db/migrate/20150629075221_add_scm_type_to_repositories.rb new file mode 100644 index 00000000000..2287d9f4c60 --- /dev/null +++ b/db/migrate/20150629075221_add_scm_type_to_repositories.rb @@ -0,0 +1,53 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +## +# Adds the scm_type column to the repositories table and updates all previous +# repository to correspond to these types. +# +# As until now, OP only supported local Git repositories and local or remote Subversion +# repositories. +# We thus add the following types: +# +# - Repository::*: existing +# - Repository::Git: local +# +class AddScmTypeToRepositories < ActiveRecord::Migration + def up + add_column :repositories, :scm_type, :string, null: true + + Repository.update_all(scm_type: 'existing') + Repository.where(type: 'Repository::Git').update_all(scm_type: 'local') + + change_column_null :repositories, :scm_type, false + end + + def down + remove_column :repositories, :scm_type + end +end diff --git a/db/migrate/20150716163704_remove_filesystem_repositories.rb b/db/migrate/20150716163704_remove_filesystem_repositories.rb new file mode 100644 index 00000000000..7f6ae39878f --- /dev/null +++ b/db/migrate/20150716163704_remove_filesystem_repositories.rb @@ -0,0 +1,60 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +## +# Removes all remaining Repository::Filesystem entries. +# +class RemoveFilesystemRepositories < ActiveRecord::Migration + include Migration::Utils + + def up + + ActiveRecord::Base.transaction do + # Delete any changesets belonging to filesystem repositories + ActiveRecord::Base.connection.execute <<-SQL + DELETE FROM changesets + WHERE repository_id IN ( + SELECT id + FROM repositories + WHERE type = #{filesystem_type} + ) + SQL + + # Circumvent Repository.where(...) since this tries to load + # the Repository::Filesystem constant and fails. + ActiveRecord::Base.connection.execute <<-SQL + DELETE FROM repositories + WHERE type = #{filesystem_type} + SQL + end + end + + def filesystem_type + quote_value('Repository::Filesystem') + end +end diff --git a/db/migrate/20150729145732_add_storage_information_to_repository.rb b/db/migrate/20150729145732_add_storage_information_to_repository.rb new file mode 100644 index 00000000000..57781c50f8c --- /dev/null +++ b/db/migrate/20150729145732_add_storage_information_to_repository.rb @@ -0,0 +1,34 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ +class AddStorageInformationToRepository < ActiveRecord::Migration + def change + add_column :repositories, :required_storage_bytes, :integer, + limit: 8, null: false, default: 0 + add_column :repositories, :storage_updated_at, :datetime + end +end diff --git a/db/seeds.rb b/db/seeds.rb index ddae1b73f12..0a9016f50c5 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -27,8 +27,9 @@ # See doc/COPYRIGHT.rdoc for more details. #++ -# This file should contain all the record creation needed to seed the database with its default values. -# The data can then be loaded with the rake db:seed (or created alongside the db with db:setup). +# This file should contain all the record creation needed to seed the database +# with its default values. The data can then be loaded with the rake db:seed +# (or created alongside the db with db:setup). # # Examples: # @@ -49,6 +50,20 @@ ActiveRecord::Base.descendants.each do |klass| klass.reset_column_information end +# willfully ignoring Redmine::I18n and it's +# #set_language_if_valid here as it +# would mean to circumvent the default settings +# for valid_languages. +include Redmine::I18n +desired_lang = (ENV['LOCALE'] || :en).to_sym + +if all_languages.include?(desired_lang) + I18n.locale = desired_lang + puts "*** Seeding for locale: '#{I18n.locale}'" +else + raise "Locale #{desired_lang} is not supported" +end + ['all', Rails.env].each do |seed| seed_file = "#{Rails.root}/db/seeds/#{seed}.rb" if File.exists?(seed_file) diff --git a/spec/lib/redmine/scm/base_spec.rb b/db/seeds/activities.rb similarity index 54% rename from spec/lib/redmine/scm/base_spec.rb rename to db/seeds/activities.rb index 0b4aebb47b6..7a5aa034fac 100644 --- a/spec/lib/redmine/scm/base_spec.rb +++ b/db/seeds/activities.rb @@ -27,44 +27,39 @@ # See doc/COPYRIGHT.rdoc for more details. #++ -require 'spec_helper' +if TimeEntryActivity.any? + puts '***** Skipping activities as there are already some configured' +else + TimeEntryActivity.transaction do + TimeEntryActivity.new.tap do |activity| + activity.name = I18n.t(:default_activity_management) + activity.position = 1 + activity.is_default = true + end.save! -describe Redmine::Scm::Base do - describe '.configured' do - subject { described_class.configured } + TimeEntryActivity.new.tap do |activity| + activity.name = I18n.t(:default_activity_specification) + activity.position = 2 + end.save! - let(:test_scm_class) do - Class.new - end + TimeEntryActivity.new.tap do |activity| + activity.name = I18n.t(:default_activity_development) + activity.position = 3 + end.save! - before do - Repository.const_set('TestScm', test_scm_class) - Redmine::Scm::Base.add 'TestScm' - end + TimeEntryActivity.new.tap do |activity| + activity.name = I18n.t(:default_activity_testing) + activity.position = 4 + end.save! - after do - Repository.send(:remove_const, :TestScm) - Redmine::Scm::Base.delete 'TestScm' - end + TimeEntryActivity.new.tap do |activity| + activity.name = I18n.t(:default_activity_support) + activity.position = 5 + end.save! - context 'scm is configured' do - before do - allow(test_scm_class).to receive(:configured?).and_return(true) - end - - it 'is included' do - is_expected.to include('TestScm') - end - end - - context 'scm is not configured' do - before do - allow(test_scm_class).to receive(:configured?).and_return(false) - end - - it 'is included' do - is_expected.to_not include('TestScm') - end - end + TimeEntryActivity.new.tap do |activity| + activity.name = I18n.t(:default_activity_other) + activity.position = 6 + end.save! end end diff --git a/db/seeds/admin_user.rb b/db/seeds/admin_user.rb new file mode 100644 index 00000000000..cdfb4093a16 --- /dev/null +++ b/db/seeds/admin_user.rb @@ -0,0 +1,50 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +if User.admin.empty? + user = User.new + + user.admin = true + user.login = 'admin' + user.password = '!AdminAdminAdmin123%&/' + user.firstname = 'OpenProject' + user.lastname = 'Admin' + user.mail = ENV.fetch('ADMIN_EMAIL') { 'admin@example.net' } + user.mail_notification = User::USER_MAIL_OPTION_NON.first + user.language = I18n.locale.to_s + user.status = User::STATUSES[:active] + user.save! + + # Enable the user to login easily but force him + # to change his password right away unless we are + # only seeding the development database. + user.force_password_change = Rails.env != 'development' + user.password = 'admin' + user.save(validate: false) +end diff --git a/db/seeds/all.rb b/db/seeds/all.rb index 89ff20c0647..f6a6971aa23 100644 --- a/db/seeds/all.rb +++ b/db/seeds/all.rb @@ -29,61 +29,9 @@ # add seeds here, that need to be available in all environments -# Magic code. This useless use of a constant in void context actually -# has autoloader side-effects that allow the circular dependency (User-> -# Principal-> Project-> User) to exist well into the future. Hooray for -# not fixing stuff! -Project +%w{ builtin_roles + admin_user }.each do |file| + puts "**** #{file}" -PlanningElementTypeColor.colors.map(&:save) -default_color = PlanningElementTypeColor.find_by(name: 'Grey-light') - -::Type.find_or_create_by(is_standard: true) do |type| - type.name = 'none' - type.position = 0 - type.color_id = default_color.id - type.is_default = true - type.is_in_roadmap = true - type.in_aggregation = true - type.is_milestone = false -end - -if Role.find_by(builtin: Role::BUILTIN_NON_MEMBER).nil? - role = Role.new - - role.name = 'Non member' - role.position = 0 - role.builtin = Role::BUILTIN_NON_MEMBER - role.save! -end - -if Role.find_by(builtin: Role::BUILTIN_ANONYMOUS).nil? - role = Role.new - - role.name = 'Anonymous' - role.position = 1 - role.builtin = Role::BUILTIN_ANONYMOUS - role.save! -end - -if User.admin.empty? - user = User.new - - old_password_length = Setting.password_min_length - Setting.password_min_length = 0 - - user.admin = true - user.login = 'admin' - user.password = 'admin' - # force password change on first login - user.force_password_change = true - user.firstname = 'OpenProject' - user.lastname = 'Admin' - user.mail = ENV.fetch('ADMIN_EMAIL') { 'admin@example.net' } - user.mail_notification = User::USER_MAIL_OPTION_NON.first - user.language = 'en' - user.status = 1 - user.save! - - Setting.password_min_length = old_password_length + require "#{Rails.root}/db/seeds/#{file}" end diff --git a/spec/support/filesystem_repo.rb b/db/seeds/basic_setup.rb similarity index 71% rename from spec/support/filesystem_repo.rb rename to db/seeds/basic_setup.rb index 5811eef6134..aadcd8046d7 100644 --- a/spec/support/filesystem_repo.rb +++ b/db/seeds/basic_setup.rb @@ -27,25 +27,14 @@ # See doc/COPYRIGHT.rdoc for more details. #++ -def with_created_filesystem_repository(&block) - let(:repository) do - repo = FactoryGirl.build(:repository) +%w{ roles + activities + colors + workflows + priorities + project_statuses + project_types }.each do |file| + puts "**** #{file}" - # ignoring the bugs on url as those are expected: - # 1) directory is not existing - # 2) configuration is not whitelisting the directory - if repo.valid? || (repo.errors.keys - [:url]).empty? - repo.save(validate: false) - else - repo.save! - end - - repo - end - - before do - allow(Setting).to receive(:enabled_scm).and_return(['Filesystem']) - end - - block.call + require "#{Rails.root}/db/seeds/#{file}" end diff --git a/app/workers/deliver_work_package_created_job.rb b/db/seeds/builtin_roles.rb similarity index 75% rename from app/workers/deliver_work_package_created_job.rb rename to db/seeds/builtin_roles.rb index 0368cad08fd..9ce65ce37be 100644 --- a/app/workers/deliver_work_package_created_job.rb +++ b/db/seeds/builtin_roles.rb @@ -27,19 +27,20 @@ # See doc/COPYRIGHT.rdoc for more details. #++ -class DeliverWorkPackageCreatedJob < MailNotificationJob - def initialize(recipient_id, work_package_id, author_id) - super(recipient_id, author_id) - @work_package_id = work_package_id - end +if Role.find_by_builtin(Role::BUILTIN_NON_MEMBER).nil? + role = Role.new - private - - def notification_mail - @notification_mail ||= UserMailer.work_package_added(recipient, work_package, author) - end - - def work_package - @work_package ||= WorkPackage.find(@work_package_id) - end + role.name = 'Non member' + role.position = 0 + role.builtin = Role::BUILTIN_NON_MEMBER + role.save! +end + +if Role.find_by_builtin(Role::BUILTIN_ANONYMOUS).nil? + role = Role.new + + role.name = 'Anonymous' + role.position = 1 + role.builtin = Role::BUILTIN_ANONYMOUS + role.save! end diff --git a/db/seeds/colors.rb b/db/seeds/colors.rb new file mode 100644 index 00000000000..497ca08d45a --- /dev/null +++ b/db/seeds/colors.rb @@ -0,0 +1,76 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +if PlanningElementTypeColor.any? + puts '***** Skipping colors as there are already some configured' +else + PlanningElementTypeColor.transaction do + PlanningElementTypeColor.create(name: I18n.t(:default_color_blue_dark), + hexcode: '#06799F') + + PlanningElementTypeColor.create(name: I18n.t(:default_color_blue), + hexcode: '#3493B3') + + PlanningElementTypeColor.create(name: I18n.t(:default_color_blue_light), + hexcode: '#00B0F0') + + PlanningElementTypeColor.create(name: I18n.t(:default_color_green_light), + hexcode: '#35C53F') + + PlanningElementTypeColor.create(name: I18n.t(:default_color_green_dark), + hexcode: '#339933') + + PlanningElementTypeColor.create(name: I18n.t(:default_color_yellow), + hexcode: '#FFFF00') + + PlanningElementTypeColor.create(name: I18n.t(:default_color_orange), + hexcode: '#FFCC00') + + PlanningElementTypeColor.create(name: I18n.t(:default_color_red), + hexcode: '#FF3300') + + PlanningElementTypeColor.create(name: I18n.t(:default_color_magenta), + hexcode: '#E20074') + + PlanningElementTypeColor.create(name: I18n.t(:default_color_white), + hexcode: '#FFFFFF') + + PlanningElementTypeColor.create(name: I18n.t(:default_color_grey_light), + hexcode: '#F8F8F8') + + PlanningElementTypeColor.create(name: I18n.t(:default_color_grey), + hexcode: '#EAEAEA') + + PlanningElementTypeColor.create(name: I18n.t(:default_color_grey_dark), + hexcode: '#878787') + + PlanningElementTypeColor.create(name: I18n.t(:default_color_black), + hexcode: '#000000') + end +end diff --git a/db/seeds/development.rb b/db/seeds/development.rb index 4e205e0e00a..2fd814cebb2 100644 --- a/db/seeds/development.rb +++ b/db/seeds/development.rb @@ -27,29 +27,16 @@ # See doc/COPYRIGHT.rdoc for more details. #++ -# remove password-reset flag from admin user -# (the default password is OK in dev mode) -admin = User.where(login: 'admin').first -if admin && admin.force_password_change? - admin.force_password_change = false - admin.save! -end +# Disable mail delivery for the duration of this task +ActionMailer::Base.perform_deliveries = false -# set some sensible defaults: -include Redmine::I18n - -# sensible shortcut: Create the default data in english -begin - set_language_if_valid('en') - Redmine::DefaultData::Loader.load(current_language) - puts 'Default configuration data loaded.' -rescue Redmine::DefaultData::DataAlreadyLoaded - puts 'Redmine Default-Data already loaded' -end +# Avoid asynchronous DeliverWorkPackageCreatedJob +Delayed::Worker.delay_jobs = false user_count = ENV.fetch('SEED_USER_COUNT', 3).to_i -# Careful: The seeding recreates the seeded project before it runs, so any changes on the seeded project will be lost. +# Careful: The seeding recreates the seeded project before it runs, so any changes +# on the seeded project will be lost. puts 'Creating seeded project...' if delete_me = Project.find_by(identifier: 'seeded_project') delete_me.destroy @@ -107,29 +94,9 @@ time_entry_activities = [] time_entry_activities << time_entry_activity end -repo_url_setting = OpenProject::Configuration['scm_filesystem_path_whitelist'] -repository = if repo_url_setting.empty? - puts <<-MESSAGE -* = + = * = + = * = + = * = + = * = + = * = + = * = + = * = + = * = + = * = + = * = + = * = + = - -Filesystem based repositories are not configured. No repository and no changeset will be created. - -In case you want those, define whitelisted repositories in your configuration.yml. -See config/configuration.yml.example for details. - -* = + = * = + = * = + = * = + = * = + = * = + = * = + = * = + = * = + = * = + = * = + = * = + = - - MESSAGE - - nil - else - Setting.enabled_scm = (Setting.enabled_scm.dup << 'Filesystem').uniq - - repo_url = Dir.glob(repo_url_setting).first - - Repository::Filesystem.create! project: project, - url: repo_url - end +repository = Repository::Subversion.create!(project: project, + url: 'file:///tmp/foo/bar.svn', + scm_type: 'existing') print 'Creating objects for...' user_count.times do |count| diff --git a/db/seeds/priorities.rb b/db/seeds/priorities.rb new file mode 100644 index 00000000000..873107d5f8b --- /dev/null +++ b/db/seeds/priorities.rb @@ -0,0 +1,55 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +if IssuePriority.any? + puts '***** Skipping priorities as there are already some configured' +else + IssuePriority.transaction do + IssuePriority.new.tap do |priority| + priority.name = I18n.t(:default_priority_low) + priority.position = 1 + end.save! + + IssuePriority.new.tap do |priority| + priority.name = I18n.t(:default_priority_normal) + priority.position = 2 + priority.is_default = true + end.save! + + IssuePriority.new.tap do |priority| + priority.name = I18n.t(:default_priority_high) + priority.position = 3 + end.save! + + IssuePriority.new.tap do |priority| + priority.name = I18n.t(:default_priority_immediate) + priority.position = 4 + end.save! + end +end diff --git a/db/seeds/production.rb b/db/seeds/production.rb index 72c6f772ce2..3b3b993157b 100644 --- a/db/seeds/production.rb +++ b/db/seeds/production.rb @@ -29,30 +29,4 @@ # add seeds specific for the production-environment here -standard_type = ::Type.find_by(is_standard: true) - -# Adds the standard type to all existing projects -# -# As this seed might be executed on an existing database, there might be projects -# that do not have the default type yet. - -projects_without_standard_type = Project.where("NOT EXISTS (SELECT * from projects_types WHERE projects.id = projects_types.project_id AND projects_types.type_id = #{standard_type.id})") - .all - -projects_without_standard_type.each do |project| - project.types << standard_type -end - -# Fixes work packages that do not have a type yet. They receive the standard type. -# -# This can happen when an existing database, having timelines planning elements, -# gets migrated. During the migration, the existing planning elements are converted -# to work_packages. Because the existance of a standard type cannot be guaranteed -# during the migration, such work packages receive a type_id of 0. -# -# Because all work packages that do not a type yet should always have had one -# (from todays standpoint) the assignment is done covertedly. - -[WorkPackage, Journal::WorkPackageJournal].each do |klass| - klass.where(type_id: [0, nil]).update_all(type_id: standard_type.id) -end +require "#{Rails.root}/db/seeds/basic_setup" diff --git a/db/seeds/project_statuses.rb b/db/seeds/project_statuses.rb new file mode 100644 index 00000000000..9a3b41b6d29 --- /dev/null +++ b/db/seeds/project_statuses.rb @@ -0,0 +1,49 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +if ReportedProjectStatus.any? + puts '***** Skipping reported project status as there are already some configured' +else + ReportedProjectStatus.transaction do + ReportedProjectStatus.new.tap do |status| + status.name = I18n.t(:default_reported_project_status_green) + status.is_default = true + end.save! + + ReportedProjectStatus.new.tap do |status| + status.name = I18n.t(:default_reported_project_status_amber) + status.is_default = false + end.save! + + ReportedProjectStatus.new.tap do |status| + status.name = I18n.t(:default_reported_project_status_red) + status.is_default = false + end.save! + end +end diff --git a/db/seeds/project_types.rb b/db/seeds/project_types.rb new file mode 100644 index 00000000000..6194c7f3b15 --- /dev/null +++ b/db/seeds/project_types.rb @@ -0,0 +1,50 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +if ProjectType.any? + puts '***** Skipping project types as there are already some configured' +elsif !ReportedProjectStatus.any? + puts '***** Skipping project types as it required to have reported project status' +else + + ProjectType.transaction do + ProjectType.new.tap do |type| + type.name = I18n.t(:default_project_type_scrum) + end.save! + + ProjectType.new.tap do |type| + type.name = I18n.t(:default_project_type_standard) + end.save! + + reported_status_ids = ReportedProjectStatus.pluck(:id) + ProjectType.all.each { |project_type| + project_type.update_attributes(reported_project_status_ids: reported_status_ids) + } + end +end diff --git a/db/seeds/roles.rb b/db/seeds/roles.rb new file mode 100644 index 00000000000..db5b09b1d7b --- /dev/null +++ b/db/seeds/roles.rb @@ -0,0 +1,128 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +if Role.where(builtin: false).any? + puts '***** Skipping roles as there are already some configured' +else + Role.transaction do + Role.new.tap do |role| + role.name = I18n.t(:default_role_project_admin) + role.position = 3 + role.permissions = Role.new.setable_permissions.map(&:name) + end.save! + + Role.new.tap do |role| + role.name = I18n.t(:default_role_member) + role.position = 4 + role.permissions = [:view_work_packages, + :export_work_packages, + :add_work_packages, + :move_work_packages, + :edit_work_packages, + :add_work_package_notes, + :edit_own_work_package_notes, + :manage_work_package_relations, + :manage_subtasks, + :manage_public_queries, + :save_queries, + :view_work_package_watchers, + :add_work_package_watchers, + :delete_work_package_watchers, + :view_calendar, + :comment_news, + :log_time, + :view_time_entries, + :edit_own_time_entries, + :view_project_associations, + :view_timelines, + :edit_timelines, + :delete_timelines, + :view_reportings, + :edit_reportings, + :delete_reportings, + :manage_wiki, + :manage_wiki_menu, + :rename_wiki_pages, + :change_wiki_parent_page, + :delete_wiki_pages, + :view_wiki_pages, + :export_wiki_pages, + :view_wiki_edits, + :edit_wiki_pages, + :delete_wiki_pages_attachments, + :protect_wiki_pages, + :list_attachments, + :add_messages, + :edit_own_messages, + :delete_own_messages, + :browse_repository, + :view_changesets, + :commit_access, + :view_commit_author_statistics] + end.save! + + Role.new.tap do |reader| + reader.name = I18n.t(:default_role_reader) + reader.position = 5 + reader.permissions = [:view_work_packages, + :add_work_package_notes, + :edit_own_work_package_notes, + :save_queries, + :view_calendar, + :comment_news, + :view_project_associations, + :view_timelines, + :view_reportings, + :view_wiki_pages, + :export_wiki_pages, + :view_wiki_edits, + :edit_wiki_pages, + :list_attachments, + :add_messages, + :edit_own_messages, + :delete_own_messages, + :browse_repository, + :view_changesets] + end.save! + + Role.non_member.update_attributes name: I18n.t(:default_role_non_member), + permissions: [:view_work_packages, + :view_calendar, + :comment_news, + :browse_repository, + :view_changesets, + :view_wiki_pages] + + Role.anonymous.update_attributes name: I18n.t(:default_role_anonymous), + permissions: [:view_work_packages, + :browse_repository, + :view_changesets, + :view_wiki_pages] + end +end diff --git a/db/seeds/workflows.rb b/db/seeds/workflows.rb new file mode 100644 index 00000000000..6cbe88f00cc --- /dev/null +++ b/db/seeds/workflows.rb @@ -0,0 +1,384 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +colors = PlanningElementTypeColor.all +colors = colors.map { |c| { c.name => c.id } }.reduce({}, :merge) + +if WorkPackage.where(type_id: nil).any? || Journal::WorkPackageJournal.where(type_id: nil).any? + # Fixes work packages that do not have a type yet. They receive the standard type. + # + # This can happen when an existing database, having timelines planning elements, + # gets migrated. During the migration, the existing planning elements are converted + # to work_packages. Because the existance of a standard type cannot be guaranteed + # during the migration, such work packages receive a type_id of nil. + # + # Because all work packages that do not have a type yet should always have had one + # (from todays standpoint). The assignment is done covertedly. + + WorkPackage.transaction do + green_color = colors[I18n.t(:default_color_green_light)] + standard_type = Type.find_or_create_by_is_standard(true, name: 'none', + position: 0, + color_id: green_color, + is_default: true, + is_in_roadmap: true, + in_aggregation: true, + is_milestone: false) + + # Adds the standard type to all existing projects + # + # As this seed might be executed on an existing database, there might be projects + # that do not have the default type yet. + + condition = "NOT EXISTS + (SELECT * from projects_types + WHERE projects.id = projects_types.project_id + AND projects_types.type_id = #{standard_type.id})" + + projects_without_standard_type = Project.where(condition).all + + projects_without_standard_type.each do |project| + project.types << standard_type + end + + [WorkPackage, Journal::WorkPackageJournal].each do |klass| + klass.where(type_id: nil).update_all(type_id: standard_type.id) + end + end +end + +if Type.where(is_standard: false).any? || Status.any? || Workflow.any? + puts '***** Skipping types, statuses and workflows as there are already some configured' +elsif Role.where(name: I18n.t(:default_role_member)).empty? || + Role.where(name: I18n.t(:default_role_project_admin)).empty? + + puts '***** Skipping types, statuses and workflows as the required roles do not exist' +else + + Type.transaction do + task = Type.new.tap do |type| + type.name = I18n.t(:default_type_task) + type.color_id = colors[I18n.t(:default_color_grey)] + type.is_default = true + type.is_in_roadmap = true + type.in_aggregation = false + type.is_milestone = false + type.position = 1 + end + + task.save! + + milestone = Type.new.tap do |type| + type.name = I18n.t(:default_type_milestone) + type.color_id = colors[I18n.t(:default_color_green_light)] + type.is_default = false + type.is_in_roadmap = false + type.in_aggregation = true + type.is_milestone = true + type.position = 2 + end + + milestone.save! + + phase = Type.new.tap do |type| + type.name = I18n.t(:default_type_phase) + type.color_id = colors[I18n.t(:default_color_blue_dark)] + type.is_default = false + type.is_in_roadmap = false + type.in_aggregation = true + type.is_milestone = false + type.position = 3 + end + + phase.save! + + feature = Type.new.tap do |type| + type.name = I18n.t(:default_type_feature) + type.color_id = colors[I18n.t(:default_color_blue)] + type.is_default = false + type.is_in_roadmap = true + type.in_aggregation = false + type.is_milestone = false + type.position = 4 + end + + feature.save! + + epic = Type.new.tap do |type| + type.name = I18n.t(:default_type_epic) + type.color_id = colors[I18n.t(:default_color_orange)] + type.is_default = false + type.is_in_roadmap = true + type.in_aggregation = true + type.is_milestone = false + type.position = 5 + end + + epic.save! + + user_story = Type.new.tap do |type| + type.name = I18n.t(:default_type_user_story) + type.color_id = colors[I18n.t(:default_color_grey_dark)] + type.is_default = false + type.is_in_roadmap = true + type.in_aggregation = false + type.is_milestone = false + type.position = 6 + end + + user_story.save! + + bug = Type.new.tap do |type| + type.name = I18n.t(:default_type_bug) + type.is_default = false + type.color_id = colors[I18n.t(:default_color_red)] + type.is_in_roadmap = true + type.in_aggregation = false + type.is_milestone = false + type.position = 7 + end + + bug.save! + + new = Status.new.tap do |type| + type.name = I18n.t(:default_status_new) + type.is_closed = false + type.is_default = true + type.position = 1 + end + + new.save! + + in_specification = Status.new.tap do |type| + type.name = I18n.t(:default_status_in_specification) + type.is_closed = false + type.is_default = false + type.position = 2 + end + + in_specification.save! + + specified = Status.new.tap do |type| + type.name = I18n.t(:default_status_specified) + type.is_closed = false + type.is_default = false + type.position = 3 + end + + specified.save! + + confirmed = Status.new.tap do |type| + type.name = I18n.t(:default_status_confirmed) + type.is_closed = false + type.is_default = false + type.position = 4 + end + + confirmed.save! + + to_be_scheduled = Status.new.tap do |type| + type.name = I18n.t(:default_status_to_be_scheduled) + type.is_closed = false + type.is_default = false + type.position = 5 + end + + to_be_scheduled.save! + + scheduled = Status.new.tap do |type| + type.name = I18n.t(:default_status_scheduled) + type.is_closed = false + type.is_default = false + type.position = 6 + end + + scheduled.save! + + in_progress = Status.new.tap do |type| + type.name = I18n.t(:default_status_in_progress) + type.is_closed = false + type.is_default = false + type.position = 7 + end + + in_progress.save! + + in_development = Status.new.tap do |type| + type.name = I18n.t(:default_status_in_development) + type.is_closed = false + type.is_default = false + type.position = 8 + end + + in_development.save! + + developed = Status.new.tap do |type| + type.name = I18n.t(:default_status_developed) + type.is_closed = false + type.is_default = false + type.position = 9 + end + + developed.save! + + in_testing = Status.new.tap do |type| + type.name = I18n.t(:default_status_in_testing) + type.is_closed = false + type.is_default = false + type.position = 10 + end + + in_testing.save! + + tested = Status.new.tap do |type| + type.name = I18n.t(:default_status_tested) + type.is_closed = false + type.is_default = false + type.position = 11 + end + + tested.save! + + test_failed = Status.new.tap do |type| + type.name = I18n.t(:default_status_test_failed) + type.is_closed = false + type.is_default = false + type.position = 12 + end + + test_failed.save! + + closed = Status.new.tap do |type| + type.name = I18n.t(:default_status_closed) + type.is_closed = true + type.is_default = false + type.position = 13 + end + + closed.save! + + on_hold = Status.new.tap do |type| + type.name = I18n.t(:default_status_on_hold) + type.is_closed = false + type.is_default = false + type.position = 14 + end + + on_hold.save! + + rejected = Status.new.tap do |type| + type.name = I18n.t(:default_status_rejected) + type.is_default = false + type.is_closed = true + type.position = 15 + end + + rejected.save! + + member = Role.where(name: I18n.t(:default_role_member)).first + manager = Role.where(name: I18n.t(:default_role_project_admin)).first + + # Workflow - Each type has its own workflow + workflows = { task.id => [new, + in_progress, + on_hold, + rejected, + closed], + milestone.id => [new, + to_be_scheduled, + scheduled, + in_progress, + on_hold, + rejected, + closed], + phase.id => [new, + to_be_scheduled, + scheduled, + in_progress, + on_hold, + rejected, + closed], + feature.id => [new, + in_specification, + specified, + in_development, + developed, + in_testing, + tested, + test_failed, + on_hold, + rejected, + closed], + epic.id => [new, + in_specification, + specified, + in_development, + developed, + in_testing, + tested, + test_failed, + on_hold, + rejected, + closed], + user_story.id => [new, + in_specification, + specified, + in_development, + developed, + in_testing, + tested, + test_failed, + on_hold, + rejected, + closed], + bug.id => [new, + confirmed, + in_development, + developed, + in_testing, + tested, + test_failed, + on_hold, + rejected, + closed] } + + workflows.each { |type_id, statuses_for_type| + statuses_for_type.each { |old_status| + statuses_for_type.each { |new_status| + [manager.id, member.id].each { |role_id| + Workflow.create type_id: type_id, + role_id: role_id, + old_status_id: old_status.id, + new_status_id: new_status.id + } + } + } + } + end +end diff --git a/doc/apiv3-documentation.apib b/doc/apiv3-documentation.apib index bdacfc8f567..8165e3778d5 100644 --- a/doc/apiv3-documentation.apib +++ b/doc/apiv3-documentation.apib @@ -2139,6 +2139,84 @@ For more details and all possible responses see the general specification of [Fo "message": "The request body was not empty." } + +# Group Revisions + +Revisions are sets of updates to files in the context of repositories linked in OpenProject. + +## Linked Properties +| Link | Description | Type | Constraints | Supported operations | +|:----------------:| --------------------------------------------------------------------------------------------------| ------------- | ----------- | -------------------- | +| self | This revision | Revision | not null | READ | +| project | The project to which the revision belongs | Project | not null | READ | +| author | The user that added this revision, if the authorName was mapped to a user in OpenProject | User | | READ | + + +## Local Properties +| Property | Description | Type | Constraints | Supported operations | +|:--------------:| ------------------------------------------------------------------------------------------------------------------------------------------------------------| ----------- | ----------- | -------------------- | +| id | Revisions's id, assigned by OpenProject | Integer | x > 0 | READ | +| identifier | The SCM identifier of the revision (e.g. SHA hash) | String | not null | READ | +| authorName | The name of the author that committed this revision. Note that this name is retrieved from the repository and does not identify a user in OpenProject. | String | not null | READ | +| message | The commit message of the revision | Formattable | not null | READ | +| createdAt | The time this revision was committed to the repository | DateTime | not null | READ | + +## Revision [/api/v3/revisions/{id}] + ++ Model + + Body + + { + "_type": "Revision", + "_links": { + "self": { + "href": "/api/v3/revisions/1" + }, + "project" { + "href": "/api/v3/projects/1" + }, + "author": { + "href": "/api/v3/users/1" + }, + }, + "id": 1, + "identifier": "11f4b07dff4f4ce9548a52b7d002daca7cd63ec6", + "authorName": "Some Developer", + "message": { + "format": "plain", + "raw": "This revision provides new features\n\nAn elaborate description", + "html": "

    This revision provides new features

    An elaborate description

    " + }, + "createdAt": "2015-07-21T13:36:59Z" + } + +## View revision [GET] + ++ Parameters + + id (required, integer, `1`) ... Revision id + ++ Response 200 (application/hal+json) + + [Revision][] + ++ Response 404 (application/hal+json) + + Returned if the revision does not exist or the client does not have sufficient permissions + to see it. + + **Required permission:** view changesets for the project the repository is created in. + + *Note: A client without sufficient permissions shall not be able to test for the existence of a revision. + That's why a 404 is returned here, even if a 403 might be more appropriate.* + + + Body + + { + "_type": "Error", + "errorIdentifier": "urn:openproject-org:api:v3:errors:NotFound", + "message": "The specified revision does not exist." + } + # Group Schemas ## Linked Properties @@ -3156,6 +3234,7 @@ Note that due to sharing this might be more than the versions *defined* by that | priority | The priority of the work package | Priority | not null | READ / WRITE | | | project | The project to which the work package belongs | Project | not null | READ | | | responsible | The person that is responsible for the overall outcome | User | | READ / WRITE | | +| revisions | Revisions that are referencing the work package | Revision | | READ | **Permission** view changesets | | status | The current status of the work package | Status | not null | READ / WRITE | | | timeEntries | All time entries logged on the work package. Please note that this is a link to an HTML resource for now and as such, the link is subject to change. | N/A | | READ | **Permission** view time entries | | type | The type of the work package | Type | not null | READ / WRITE | | @@ -3251,6 +3330,9 @@ the human readable name of custom fields.* "href": "/api/v3/users/23", "title": "Laron Leuschke - Alaina5788" }, + "revisions": { + "href": "/api/v3/work_packages/1528/revisions" + }, "assignee": { "href": "/api/v3/users/11", "title": "Emmie Okuneva - Adele5450" @@ -3937,6 +4019,120 @@ Gets a list of users that are able to be watchers of the specified work package. "message": "The specified work package does not exist." } +## Revisions [/api/v3/work_packages/{id}/revisions] + ++ Model + + Body + + { + "_links": { + "self": { "href": "/api/v3/work_packages/42/revisions" }, + "filter": { + "href": "/api/v3/work_packages/42/revisions", + "templated": true + } + }, + "total": 2, + "count": 2, + "_type": "Collection", + "_embedded": { + "elements": [ + { + "_type": "Revision", + "_links": { + "self": { + "href": "/api/v3/revisions/13", + }, + "project": { + "href": "/api/v3/projects/1", + "title": "A Test Project + }, + "author": { + "href": "/api/v3/users/1", + "title": "John Sheppard - j.sheppard" + } + }, + "id": 13, + "identifier": "11f4b07dff4f4ce9548a52b7d002daca7cd63ec6", + "authorName": "John Sheppard", + "message": { + "format": "plain", + "raw": "This revision provides new features\n\nAn elaborate description", + "html": "

    This revision provides new features

    An elaborate description

    " + }, + "createdAt": "2015-07-21T13:36:59Z", + }, + { + "_type": "Revision", + "_links": { + "self": { + "href": "/api/v3/revisions/14", + }, + "project": { + "href": "/api/v3/projects/1", + "title": "A Test Project + }, + "author": { + "href": "/api/v3/users/2", + "title": "Jim Sheppard - j.sheppard" + } + }, + "id": 13, + "identifier": "029ed72a3b7b7c4ab332b1f6eaa6576e7c946059", + "authorName": "j1msheppard", + "message": { + "format": "plain", + "raw": "This revision fixes some stuff\n\nMore information here", + "html": "

    This revision fixes some stuff

    More information here

    " + }, + "createdAt": "2015-06-30T08:47:00Z", + }] + } + } + + +## Revisions [GET] + +Gets a list of revisions that are linked to this work package, e.g., because it is referenced in the commit message of the revision. + ++ Parameters + + id (required, integer, `1`) ... work package id + ++ Response 200 (application/hal+json) + + [Revisions][] + ++ Response 403 (application/hal+json) + + Returned if the client does not have sufficient permissions. + + **Required permission:** view changesets of the project this work package is contained in + + *Note that you will only receive this error, if you are at least allowed to see the corresponding work package.* + + + Body + + { + "_type": "Error", + "errorIdentifier": "urn:openproject-org:api:v3:errors:MissingPermission", + "message": "You are not allowed to see linked revisions for this work package." + } + ++ Response 404 (application/hal+json) + + Returned if the work package does not exist or the client does not have sufficient permissions to see it. + + **Required permission:** view work package + + + Body + + { + "_type": "Error", + "errorIdentifier": "urn:openproject-org:api:v3:errors:NotFound", + "message": "The specified work project does not exist." + } + + ## Comment WorkPackage [/api/v3/work_packages/{id}/activities] ## Comment work package [POST] diff --git a/doc/operation_guides/manual/installation-guide.md b/doc/operation_guides/manual/installation-guide.md index 7ae858eb472..3fa755f1b7f 100644 --- a/doc/operation_guides/manual/installation-guide.md +++ b/doc/operation_guides/manual/installation-guide.md @@ -222,7 +222,13 @@ prevents you from such errors. [openproject@host] RAILS_ENV="production" bundle exec rake assets:precompile ``` -## Servce OpenProject with Apache and Passenger +**NOTE:** When not specified differently, the default data loaded via db:seed will have an english localization. You can choose to seed in a different language by specifying the language via the `LOCALE` environment variable on the call to `db:seed`. E.g. +```bash +[openproject@all] RAILS_ENV="production" LOCALE=fr bundle exec rake db:seed +``` +will seed the database in the french language. + +## Serve OpenProject with Apache and Passenger First, we exit the current bash session with the openproject user, so that we are again in a root shell. diff --git a/doc/operation_guides/manual/upgrade-guide.md b/doc/operation_guides/manual/upgrade-guide.md index 1fed5c06982..8777a61081a 100644 --- a/doc/operation_guides/manual/upgrade-guide.md +++ b/doc/operation_guides/manual/upgrade-guide.md @@ -128,6 +128,8 @@ Now that the sources and dependencies are in place, you can migrate the Database *Side note:* If you are using `RAILS_ENV="development"` the task `bundle exec rake assets:webpack` needs to be run. This step is not necessary for `production` because it is part of the `asset:precompile` tasks. +**NOTE** `db:seed` can also be invoked with a 'LOCALE' environment variable defined, specifying the language in which to seed. Note however, that specifying different locales for calls to `db:seed` might lead to a mixture of languages in your data. It is therefore advisable to use the same language for all calls to `db:seed`. + ## The Aftermath * Re-enable the `delayed_job` cron job that was disabled in the first step. * If you have put up a maintenance page, remove it. diff --git a/extra/svn/reposman.rb b/extra/svn/reposman.rb index ae8241ae149..68defea65c5 100755 --- a/extra/svn/reposman.rb +++ b/extra/svn/reposman.rb @@ -36,7 +36,7 @@ require 'net/http' require 'uri' Version = "1.4" -SUPPORTED_SCM = %w( Subversion Git Filesystem ) +SUPPORTED_SCM = %w( Subversion Git ) $verbose = 0 $quiet = false diff --git a/features/menu_items.feature b/features/menu_items.feature index 2e058b33e36..d63964809bd 100644 --- a/features/menu_items.feature +++ b/features/menu_items.feature @@ -46,10 +46,9 @@ Feature: Menu items When I go to the overview page of the project "Awesome Project" Then I should see "Calendar" within "#main-menu" - @javascript Scenario: Work Packages Summary should be visible and accessible When I go to the overview page of the project "Awesome Project" - And I click on "Work packages" within "#main-menu" + And I toggle the "Work packages" submenu Then I should see "Summary" within "#main-menu" When I click on "Summary" within "#main-menu" diff --git a/features/step_definitions/repository_steps.rb b/features/step_definitions/repository_steps.rb index a420029c750..148cdf68f6f 100644 --- a/features/step_definitions/repository_steps.rb +++ b/features/step_definitions/repository_steps.rb @@ -30,17 +30,10 @@ Given(/^the project "(.*?)" has a repository$/) do |project_name| project = Project.find(project_name) - repo_path = Rails.root.join 'tmp/filesystem_repository' - - FileUtils.mkdir_p repo_path - - OpenProject::Configuration['scm_filesystem_path_whitelist'] = [repo_path] - - repo = FactoryGirl.build(:repository, - url: repo_path, + repo = FactoryGirl.build(:repository_subversion, project: project) - Setting.enabled_scm = Setting.enabled_scm << repo.scm_name + Setting.enabled_scm = Setting.enabled_scm << repo.vendor repo.save! end diff --git a/features/step_definitions/timelines_when_steps.rb b/features/step_definitions/timelines_when_steps.rb index 82ef6df274a..5720fac3666 100644 --- a/features/step_definitions/timelines_when_steps.rb +++ b/features/step_definitions/timelines_when_steps.rb @@ -183,11 +183,11 @@ When (/^I set the second level grouping criteria to "(.*?)" for the timeline "(. } project_type = ProjectType.find_by(name: project_type_name) - page.should have_selector('#timeline_options_grouping_two_enabled', visible: false) + check(I18n.t('timelines.filter.grouping_two')) + fill_in(I18n.t('timelines.filter.grouping_two_phrase'), with: project_type.name) + find('.select2-result-label', text: project_type.name).click - page.execute_script("jQuery('#timeline_options_grouping_two_enabled').prop('checked', true)") - page.execute_script("jQuery('#timeline_options_grouping_two_selection').val('#{project_type.id}')") - page.execute_script("jQuery('#content form').submit()") + click_button(I18n.t(:button_save)) end When (/^I set the columns shown in the timeline to:$/) do |table| steps %{ @@ -256,6 +256,9 @@ When (/^I set duedate to "([^"]*)"$/) do |value| end When (/^I wait for timeline to load table$/) do + extend ::Angular::DSL unless singleton_class.included_modules.include?(::Angular::DSL) + ng_wait + page.should have_selector('.tl-left-main') end diff --git a/features/step_definitions/user_steps.rb b/features/step_definitions/user_steps.rb index 68e520300f4..e6da1ec98ad 100644 --- a/features/step_definitions/user_steps.rb +++ b/features/step_definitions/user_steps.rb @@ -133,6 +133,10 @@ Then /^there should be a user with the following:$/ do |table| end end +Given 'journals are not being aggregated' do + Setting.journal_aggregation_time_minutes = 0 +end + ## # admin users list # diff --git a/features/work_packages/diff_on_show.feature b/features/work_packages/diff_on_show.feature index a4f2c1570cf..28ad21dd22a 100644 --- a/features/work_packages/diff_on_show.feature +++ b/features/work_packages/diff_on_show.feature @@ -55,6 +55,7 @@ Feature: Having an inline diff view for work package description changes Scenario: A work package with a changed description has a callable diff showing the changes inline Given the work_package "wp1" is updated with the following: | description | Altered description | + And journals are not being aggregated When I go to the page of the work package "wp1" diff --git a/frontend/app/config/configuration-service.js b/frontend/app/config/configuration-service.js index 20b7f28d312..5ad2939a411 100644 --- a/frontend/app/config/configuration-service.js +++ b/frontend/app/config/configuration-service.js @@ -31,7 +31,34 @@ /* jshint camelcase: false */ -module.exports = function() { +module.exports = function(PathHelper, $q, $http) { + + // fetches configuration from the ApiV3 endpoint + // TODO: this currently saves the request between page reloads, + // but could easily be stored in localStorage + var cache = false, + path = PathHelper.apiConfigurationPath(), + fetchSettings = function() { + var data = $q.defer(); + $http.get(path).success(function(settings) { + data.resolve(settings); + }).error(function(err) { + data.reject(err); + }); + return data.promise; + }, + api = function() { + var settings = $q.defer(); + if (cache) { + settings.resolve(cache); + } else { + fetchSettings().then(function(data) { + cache = data; + settings.resolve(data); + }); + } + return settings.promise; + }; var initSettings = function() { var settings = {}, @@ -56,6 +83,7 @@ module.exports = function() { return { settings: initSettings(), + api: api, userPreferencesPresent: function() { return this.settings.hasOwnProperty('user_preferences'); }, diff --git a/frontend/app/helpers/path-helper.js b/frontend/app/helpers/path-helper.js index e7ff080fa1d..3d8f58d2cff 100644 --- a/frontend/app/helpers/path-helper.js +++ b/frontend/app/helpers/path-helper.js @@ -218,6 +218,9 @@ module.exports = function() { }, // API V3 + apiConfigurationPath: function() { + return PathHelper.apiV3 + '/configuration'; + }, apiQueryStarPath: function(queryId) { return PathHelper.apiV3QueryPath(queryId) + '/star'; }, diff --git a/frontend/app/openproject-app.js b/frontend/app/openproject-app.js index 69dcbd5b9c0..3052db4732d 100644 --- a/frontend/app/openproject-app.js +++ b/frontend/app/openproject-app.js @@ -44,7 +44,6 @@ require('angular-animate'); require('angular-aria'); require('angular-modal'); -// require('angular-i18n/angular-locale_en-us'); if (I18n.locale === 'de') { require('angular-i18n/angular-locale_de-de'); } @@ -60,6 +59,7 @@ require('angular-busy/dist/angular-busy.css'); require('angular-context-menu'); require('mousetrap'); +require('ngFileUpload'); // global angular.module('openproject.uiComponents', ['ui.select', 'ngSanitize']) @@ -113,7 +113,8 @@ angular.module('openproject.workPackages', [ 'openproject.workPackages.directives', 'openproject.workPackages.tabs', 'openproject.uiComponents', - 'ng-context-menu' + 'ng-context-menu', + 'ngFileUpload' ]); angular.module('openproject.workPackages.services', []); angular.module( @@ -254,8 +255,13 @@ openprojectApp require('./api'); -angular.module('openproject.config').service('ConfigurationService', require( - './config/configuration-service')); +angular.module('openproject.config') + .service('ConfigurationService', [ + 'PathHelper', + '$q', + '$http', + require('./config/configuration-service') + ]); require('./helpers'); require('./layout'); diff --git a/frontend/app/routing.js b/frontend/app/routing.js index 21ff1af048a..977478a539d 100644 --- a/frontend/app/routing.js +++ b/frontend/app/routing.js @@ -111,10 +111,6 @@ angular.module('openproject') controller: 'DetailsTabWatchersController', templateUrl: '/templates/work_packages/tabs/watchers.html' }) - .state('work-packages.list.details.attachments', { - url: '/attachments', - templateUrl: '/templates/work_packages/tabs/attachments.html' - }); }]) .run([ diff --git a/frontend/app/services/conversion-service.js b/frontend/app/services/conversion-service.js new file mode 100644 index 00000000000..eeeb659b173 --- /dev/null +++ b/frontend/app/services/conversion-service.js @@ -0,0 +1,56 @@ +//-- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +//++ + +module.exports = function() { + var ConversionService = { + //TODO: not sure if we need GigaBytes + megabytes: function(bytes) { + if (angular.isNumber(bytes)) { + return Number((bytes / 1000000).toFixed(1)); + } + return bytes; + }, + kilobytes: function(bytes) { + if (angular.isNumber(bytes)) { + return Number((bytes / 1000).toFixed(1)); + } + return bytes; + }, + fileSize: function(bytes) { + if (1000 <= bytes && bytes < 1000000) { + return ConversionService.kilobytes(bytes) + 'kB'; + } + if (bytes >= 1000000) { + return ConversionService.megabytes(bytes) + 'MB'; + } + return bytes + 'B'; + } + }; + + return ConversionService; +}; diff --git a/frontend/app/services/index.js b/frontend/app/services/index.js index 8480314114e..2c1420b416f 100644 --- a/frontend/app/services/index.js +++ b/frontend/app/services/index.js @@ -104,4 +104,10 @@ angular.module('openproject.services') 'EditableFieldsState', 'WorkPackageFieldService', require('./work-package-service') - ]); + ]) + .service('NotificationsService', [ + 'I18n', + '$rootScope', + require('./notifications-service.js') + ]) + .service('ConversionService', require('./conversion-service.js')); diff --git a/frontend/app/services/notifications-service.js b/frontend/app/services/notifications-service.js new file mode 100644 index 00000000000..a4450db44dc --- /dev/null +++ b/frontend/app/services/notifications-service.js @@ -0,0 +1,96 @@ +//-- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +//++ + +module.exports = function(I18n, $rootScope) { + + // private + var createNotification = function(message) { + if(typeof message === 'string') { + return { message: message }; + } + return message; + }, + createSuccessNotification = function(message) { + return _.extend(createNotification(message), { type: 'success' }); + }, + createWarningNotification = function(message) { + return _.extend(createNotification(message), { type: 'warning' }); + }, + createErrorNotification = function(message, errors) { + if(!errors) { + throw new Error('Cannot create an error notification without errors!'); + } + return _.extend(createNotification(message), { + type: 'error', + errors: errors + }); + }, + createWorkPackageUploadNotification = function(message, uploads) { + if(!uploads) { + throw new Error('Cannot create an upload notification without uploads!'); + } + return _.extend(createNotification(message), { + type: 'upload', + uploads: uploads + }); + }, + broadcast = function(event, data) { + $rootScope.$broadcast(event, data); + }; + + // public + var add = function(message) { + var notification = createNotification(message); + broadcast('notification.add', notification); + return notification; + }, + addError = function(message, errors) { + return add(createErrorNotification(message, errors)); + }, + addWarning = function(message) { + return add(createWarningNotification(message)); + }, + addSuccess = function(message) { + return add(createSuccessNotification(message)); + }, + addWorkPackageUpload = function(message, uploads) { + return add(createWorkPackageUploadNotification(message, uploads)); + }, + remove = function(notification) { + broadcast('notification.remove', notification); + }; + + return { + add: add, + remove: remove, + addError: addError, + addWarning: addWarning, + addSuccess: addSuccess, + addWorkPackageUpload: addWorkPackageUpload + }; +}; diff --git a/frontend/app/services/query-service.js b/frontend/app/services/query-service.js index 590cf8160f8..03dff650bac 100644 --- a/frontend/app/services/query-service.js +++ b/frontend/app/services/query-service.js @@ -395,9 +395,9 @@ module.exports = function( getQueryPath: function(query) { if (query.project_id) { - return PathHelper.projectPath(query.project_id) + PathHelper.workPackagesPath() + '?query_id=' + query.id; + return PathHelper.staticProjectWorkPackagesPath(query.project_id) + '?query_id=' + query.id; } else { - return PathHelper.workPackagesPath() + '?query_id=' + query.id; + return PathHelper.staticWorkPackagesPath() + '?query_id=' + query.id; } }, diff --git a/frontend/app/services/work-package-service.js b/frontend/app/services/work-package-service.js index 119f626320a..91143b4505e 100644 --- a/frontend/app/services/work-package-service.js +++ b/frontend/app/services/work-package-service.js @@ -276,7 +276,7 @@ module.exports = function($http, }), contentType: 'application/json; charset=utf-8' } }; - return workPackage.links.addRelation.fetch(options).then(function(relation){ + return workPackage.links.addRelation.fetch(options).then(function(relation) { return relation; }); }, diff --git a/frontend/app/templates/components/notification-box.html b/frontend/app/templates/components/notification-box.html new file mode 100644 index 00000000000..982aff48955 --- /dev/null +++ b/frontend/app/templates/components/notification-box.html @@ -0,0 +1,35 @@ +
    + × +
    +

    {{content.message}}

    +
    +
    +
    + + + + + + + + {{::I18n.t('js.label_upload_counter', { done: uploadCount, count: content.uploads.length})}} + +
    +
    +
      + +
    +
    +
    +
    + +
    +
    +
      +
    • {{error}}
    • +
    + +
    +
    +
    +
    diff --git a/frontend/app/templates/components/notifications.html b/frontend/app/templates/components/notifications.html new file mode 100644 index 00000000000..6f4e38f5152 --- /dev/null +++ b/frontend/app/templates/components/notifications.html @@ -0,0 +1,5 @@ +
    +
    + +
    +
    diff --git a/frontend/app/templates/components/upload-progress.html b/frontend/app/templates/components/upload-progress.html new file mode 100644 index 00000000000..31d83cbbb6b --- /dev/null +++ b/frontend/app/templates/components/upload-progress.html @@ -0,0 +1,8 @@ +
  • + {{file}} + {{value}}% + + + + +
  • diff --git a/frontend/app/templates/work_packages.list.details.html b/frontend/app/templates/work_packages.list.details.html index 2f9adad402d..a4547cc2e3d 100644 --- a/frontend/app/templates/work_packages.list.details.html +++ b/frontend/app/templates/work_packages.list.details.html @@ -19,10 +19,6 @@ ui-sref-active="selected"> -
  • - -
  • diff --git a/frontend/app/templates/work_packages.list.new.html b/frontend/app/templates/work_packages.list.new.html index 9456eb64507..16d6d86b22b 100644 --- a/frontend/app/templates/work_packages.list.new.html +++ b/frontend/app/templates/work_packages.list.new.html @@ -51,6 +51,7 @@
    + diff --git a/frontend/app/templates/work_packages/attachments-edit.html b/frontend/app/templates/work_packages/attachments-edit.html new file mode 100644 index 00000000000..8da2ef6305f --- /dev/null +++ b/frontend/app/templates/work_packages/attachments-edit.html @@ -0,0 +1,22 @@ +
    +
    +
    +

    + {{ ::I18n.t('js.label_attachments') }} +

    +
    +
    +
    +

    {{ ::I18n.t('js.label_drop_files') }}

    +

    {{ ::I18n.t('js.label_drop_files_hint') }}

    +
    +
    + +
    +
    diff --git a/frontend/app/templates/work_packages/attachments.html b/frontend/app/templates/work_packages/attachments.html new file mode 100644 index 00000000000..2bbc6996ae4 --- /dev/null +++ b/frontend/app/templates/work_packages/attachments.html @@ -0,0 +1,25 @@ +
    +
    +
    +

    + {{ ::I18n.t('js.label_attachments') }} +

    +
    +
    +
    +

    {{ ::I18n.t('js.label_drop_files') }}

    +

    {{ ::I18n.t('js.label_drop_files_hint') }}

    +

    {{ ::I18n.t('js.label_wait') }}

    +
    +
    +

    {{::I18n.t('js.label_files_to_upload')}}

    + +
    +
    diff --git a/frontend/app/templates/work_packages/inplace_editor/custom/display/version.html b/frontend/app/templates/work_packages/inplace_editor/custom/display/version.html index 8fd9fe5a9cb..01f8eb72429 100644 --- a/frontend/app/templates/work_packages/inplace_editor/custom/display/version.html +++ b/frontend/app/templates/work_packages/inplace_editor/custom/display/version.html @@ -1,7 +1,7 @@
    - - + {{displayPaneController.getReadValue().props.name}} diff --git a/frontend/app/templates/work_packages/tabs/_user_activity.html b/frontend/app/templates/work_packages/tabs/_user_activity.html index 737f1badea6..7d65927d246 100644 --- a/frontend/app/templates/work_packages/tabs/_user_activity.html +++ b/frontend/app/templates/work_packages/tabs/_user_activity.html @@ -22,7 +22,7 @@ Avatar {{ userName }} - {{ I18n.t('js.label_commented_on') }} + {{ isInitial ? I18n.t('js.label_created_on') : I18n.t('js.label_commented_on') }}
    @@ -49,7 +49,7 @@ class="message" ng-show="activity.props._type == 'Activity::Comment'" ng-bind-html="comment"/> -
      +
      • diff --git a/frontend/app/templates/work_packages/tabs/activity.html b/frontend/app/templates/work_packages/tabs/activity.html index d97430b6000..5322380b2ca 100644 --- a/frontend/app/templates/work_packages/tabs/activity.html +++ b/frontend/app/templates/work_packages/tabs/activity.html @@ -4,6 +4,7 @@
      • diff --git a/frontend/app/templates/work_packages/tabs/attachments.html b/frontend/app/templates/work_packages/tabs/attachments.html deleted file mode 100644 index 5bb4bf237d9..00000000000 --- a/frontend/app/templates/work_packages/tabs/attachments.html +++ /dev/null @@ -1,8 +0,0 @@ -
        - -
        -
        - -
        -
        -
        diff --git a/frontend/app/templates/work_packages/tabs/overview.html b/frontend/app/templates/work_packages/tabs/overview.html index e23680baa13..5b85176a0d7 100644 --- a/frontend/app/templates/work_packages/tabs/overview.html +++ b/frontend/app/templates/work_packages/tabs/overview.html @@ -47,6 +47,8 @@
    + +
    @@ -59,10 +61,12 @@
  • + activityNo = activities.length - $index; + isInitial = activityNo == 1;"> diff --git a/frontend/app/ui_components/attachment-icon-directive.js b/frontend/app/ui_components/attachment-icon-directive.js new file mode 100644 index 00000000000..36bb9359b25 --- /dev/null +++ b/frontend/app/ui_components/attachment-icon-directive.js @@ -0,0 +1,56 @@ +//-- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +//++ + +module.exports = function() { + var imageRegExp = new RegExp(/^image\/.*$/), + excelRegExp = new RegExp(/.*excel$/); + return { + restrict: 'E', + replace: true, + scope: { + type: '&' + }, + template: '', + link: function(scope) { + var icon = 'ticket', + type = scope.type(); + + if (imageRegExp.test(type)) { + icon = 'image1'; + } + if (excelRegExp.test(type)) { + icon = 'page-xls'; + } + if (type === 'application/pdf') { + icon = 'page-pdf'; + } + + scope.icon = icon; + } + }; +}; diff --git a/frontend/app/ui_components/index.js b/frontend/app/ui_components/index.js index 2f137742640..085a5b88fe5 100644 --- a/frontend/app/ui_components/index.js +++ b/frontend/app/ui_components/index.js @@ -104,5 +104,9 @@ angular.module('openproject.uiComponents') .directive('userField', ['PathHelper', require('./user-field-directive')]) .directive('wikiToolbar', [require('./wiki-toolbar-directive')]) .directive('zoomSlider', ['I18n', require('./zoom-slider-directive')]) + .directive('notifications', [require('./notifications-directive')]) + .directive('notificationBox', ['I18n', require('./notification-box-directive')]) + .directive('uploadProgress', [require('./upload-progress-directive')]) + .directive('attachmentIcon', [require('./attachment-icon-directive')]) .filter('ancestorsExpanded', require('./filters/ancestors-expanded-filter')) .filter('latestItems', require('./filters/latest-items-filter')); diff --git a/frontend/app/ui_components/notification-box-directive.js b/frontend/app/ui_components/notification-box-directive.js new file mode 100644 index 00000000000..53e616d4663 --- /dev/null +++ b/frontend/app/ui_components/notification-box-directive.js @@ -0,0 +1,74 @@ +//-- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +//++ + +module.exports = function(I18n) { + + var notificationBoxController = function(scope) { + scope.uploadCount = 0; + scope.show = false; + scope.I18n = I18n; + + scope.canBeHidden = function() { + return scope.content.uploads.length > 5; + }; + + scope.removable = function() { + return scope.content.type !== 'upload'; + }; + + scope.typeable = function() { + return !!scope.content.type; + }; + + scope.remove = function() { + if (scope.removable()) { + scope.$emit('notification.remove', scope.content); + } + }; + + scope.$on('upload.error', function() { + if (scope.content.type === 'upload') { + scope.content.type = 'error'; + } + }); + + scope.$on('upload.finished', function() { + scope.uploadCount += 1; + }); + }; + + return { + restrict: 'E', + replace: true, + templateUrl: '/templates/components/notification-box.html', + scope: { + content: '=' + }, + link: notificationBoxController + }; +}; diff --git a/frontend/app/ui_components/notifications-directive.js b/frontend/app/ui_components/notifications-directive.js new file mode 100644 index 00000000000..370c3905ee5 --- /dev/null +++ b/frontend/app/ui_components/notifications-directive.js @@ -0,0 +1,52 @@ +//-- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +//++ + +module.exports = function() { + + var notificationsController = function(scope) { + scope.stack = []; + + scope.$on('notification.add', function(_e, notification) { + scope.stack.push(notification); + }); + + scope.$on('notification.remove', function(_e, notification) { + _.remove(scope.stack, function(element) { + return element === notification; + }); + }); + }; + + return { + scope: true, + restrict: 'E', + replace: true, + templateUrl: '/templates/components/notifications.html', + link: notificationsController + }; +}; diff --git a/frontend/app/ui_components/upload-progress-directive.js b/frontend/app/ui_components/upload-progress-directive.js new file mode 100644 index 00000000000..0e6718ece87 --- /dev/null +++ b/frontend/app/ui_components/upload-progress-directive.js @@ -0,0 +1,59 @@ +//-- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +//++ + +module.exports = function() { + 'use strict'; + var uploadProgressController = function(scope) { + + scope.upload.progress(function(details) { + scope.file = details.config.file.name; + if (details.lengthComputable) { + scope.value = Math.round(details.loaded / details.total * 100); + } else { + // dummy value if not computable + scope.value = 10; + } + }).success(function() { + scope.value = 100; + scope.completed = true; + scope.$emit('upload.finished'); + }).error(function() { + scope.error = true; + scope.$emit('upload.error'); + }); + }; + + return { + scope: { + upload: '=' + }, + link: uploadProgressController, + replace: true, + templateUrl: '/templates/components/upload-progress.html' + }; +}; diff --git a/frontend/app/work_packages/controllers/details-tab-overview-controller.js b/frontend/app/work_packages/controllers/details-tab-overview-controller.js index afd11440928..fe6f81607e8 100644 --- a/frontend/app/work_packages/controllers/details-tab-overview-controller.js +++ b/frontend/app/work_packages/controllers/details-tab-overview-controller.js @@ -31,7 +31,9 @@ module.exports = function( WorkPackagesOverviewService, WorkPackageFieldService, EditableFieldsState, - WorkPackageDisplayHelper + WorkPackageDisplayHelper, + NotificationsService, + I18n ) { var vm = this; @@ -77,5 +79,8 @@ module.exports = function( return left.localeCompare(right); }); }); + $scope.$on('workPackageUpdatedInEditor', function() { + NotificationsService.addSuccess(I18n.t('js.label_successful_update')); + }); } }; diff --git a/frontend/app/work_packages/controllers/index.js b/frontend/app/work_packages/controllers/index.js index f1034b65043..8aa8221663a 100644 --- a/frontend/app/work_packages/controllers/index.js +++ b/frontend/app/work_packages/controllers/index.js @@ -41,6 +41,8 @@ angular.module('openproject.workPackages.controllers') 'WorkPackageFieldService', 'EditableFieldsState', 'WorkPackagesDisplayHelper', + 'NotificationsService', + 'I18n', require('./details-tab-overview-controller') ]) .constant('ADD_WATCHER_SELECT_INDEX', -1) diff --git a/frontend/app/work_packages/controllers/work-package-details-controller.js b/frontend/app/work_packages/controllers/work-package-details-controller.js index 33788d337d3..b8a13789ca8 100644 --- a/frontend/app/work_packages/controllers/work-package-details-controller.js +++ b/frontend/app/work_packages/controllers/work-package-details-controller.js @@ -174,8 +174,7 @@ module.exports = function($scope, function displayedActivities(workPackage) { var activities = workPackage.embedded.activities; - // remove first activity (assumes activities are sorted chronologically) - activities.splice(0, 1); + if ($scope.activitiesSortedInDescendingOrder) { activities.reverse(); } diff --git a/frontend/app/work_packages/directives/index.js b/frontend/app/work_packages/directives/index.js index 63584c6f3b3..fb83a6e7973 100644 --- a/frontend/app/work_packages/directives/index.js +++ b/frontend/app/work_packages/directives/index.js @@ -94,6 +94,14 @@ angular.module('openproject.workPackages.directives') 'featureFlags', 'PathHelper', require('./work-packages-table-directive') + ]) + .directive('workPackageAttachments', [ + 'WorkPackageAttachmentsService', + 'NotificationsService', + 'I18n', + 'ConfigurationService', + 'ConversionService', + require('./work-package-attachments-directive') ]); require('./inplace_editor'); diff --git a/frontend/app/work_packages/directives/inplace_editor/inplace-editor-edit-pane-directive.js b/frontend/app/work_packages/directives/inplace_editor/inplace-editor-edit-pane-directive.js index c78b1c86e40..0f9ec3a5a2d 100644 --- a/frontend/app/work_packages/directives/inplace_editor/inplace-editor-edit-pane-directive.js +++ b/frontend/app/work_packages/directives/inplace_editor/inplace-editor-edit-pane-directive.js @@ -31,7 +31,8 @@ module.exports = function( EditableFieldsState, FocusHelper, $timeout, - ApiHelper) { + ApiHelper, + $rootScope) { return { transclude: true, replace: true, @@ -43,6 +44,11 @@ module.exports = function( var vm = this; var acknowledgedValidationErrors = ['required', 'number']; + // go full retard + var uploadPendingAttachments = function(wp) { + $rootScope.$broadcast('uploadPendingAttachments', wp); + }; + this.submit = function(notify) { var fieldController = $scope.fieldController; var pendingFormChanges = getPendingFormChanges(); @@ -82,6 +88,7 @@ module.exports = function( EditableFieldsState.errors = null; } ); + uploadPendingAttachments(updatedWorkPackage); })).catch(setFailure); } else { afterError(); diff --git a/frontend/app/work_packages/directives/work-package-attachments-directive.js b/frontend/app/work_packages/directives/work-package-attachments-directive.js new file mode 100644 index 00000000000..ef002350b60 --- /dev/null +++ b/frontend/app/work_packages/directives/work-package-attachments-directive.js @@ -0,0 +1,128 @@ +//-- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +//++ + +module.exports = function( + workPackageAttachmentsService, + NotificationsService, + I18n, + ConfigurationService, + ConversionService +) { + 'use strict'; + var editMode = function(attrs) { + return typeof attrs.edit !== 'undefined'; + }; + + var attachmentsController = function(scope, element, attrs) { + scope.files = []; + + var workPackage = scope.workPackage(), + upload = function(event, workPackage) { + if (scope.files.length > 0) { + workPackageAttachmentsService.upload(workPackage, scope.files).then(function() { + scope.files = []; + loadAttachments(); + }); + } + }, + loadAttachments = function() { + if (!editMode(attrs)) { + return; + } + scope.loading = true; + workPackageAttachmentsService.load(workPackage).then(function(attachments) { + scope.attachments = attachments; + }).finally(function() { + scope.loading = false; + }); + }; + + scope.I18n = I18n; + scope.rejectedFiles = []; + scope.size = ConversionService.fileSize; + + scope.instantUpload = function() { + scope.$emit('uploadPendingAttachments', workPackage); + }; + + var currentlyRemoving = []; + scope.remove = function(file) { + currentlyRemoving.push(file); + workPackageAttachmentsService.remove(file).then(function(file) { + _.remove(scope.attachments, file); + _.remove(scope.files, file); + }).finally(function() { + _.remove(currentlyRemoving, file); + }); + }; + + scope.deleting = function(attachment) { + return _.findIndex(currentlyRemoving, attachment) > -1; + }; + + scope.$on('uploadPendingAttachments', upload); + scope.$watch('rejectedFiles', function(rejectedFiles) { + if (rejectedFiles.length === 0) { + return; + } + var errors = _.map(rejectedFiles, function(file) { + return file.name + ' (' + scope.size(file.size) + ')'; + }), + message = I18n.t('js.label_rejected_files_reason', + { maximumFilesize: scope.size(scope.maximumFileSize) } + ); + NotificationsService.addError(message, errors); + }); + + scope.fetchingConfiguration = true; + ConfigurationService.api().then(function(settings) { + scope.maximumFileSize = settings.maximumAttachmentFileSize; + // somehow, I18n cannot interpolate function results, so we need to cache this once + scope.maxFileSize = scope.size(settings.maximumAttachmentFileSize); + scope.fetchingConfiguration = false; + }); + + loadAttachments(); + }; + + return { + restrict: 'E', + replace: true, + reqire: '^workPackageField', + scope: { + workPackage: '&' + }, + templateUrl: function(element, attrs) { + if (editMode(attrs)) { + return '/templates/work_packages/attachments-edit.html'; + } + return '/templates/work_packages/attachments.html'; + }, + link: attachmentsController + }; +}; diff --git a/frontend/app/work_packages/services/index.js b/frontend/app/work_packages/services/index.js index 7308e10d16b..1663f14be3f 100644 --- a/frontend/app/work_packages/services/index.js +++ b/frontend/app/work_packages/services/index.js @@ -70,4 +70,14 @@ angular.module('openproject.workPackages.services') ]) .service('EditableFieldsState', require('./editable-fields-state') - ); + ) + .service('WorkPackageAttachmentsService', [ + 'Upload', // 'Upload' is provided by ngFileUpload + 'PathHelper', + 'I18n', + 'NotificationsService', + '$q', + '$timeout', + '$http', + require('./work-package-attachments-service') + ]); diff --git a/frontend/app/work_packages/services/work-package-attachments-service.js b/frontend/app/work_packages/services/work-package-attachments-service.js new file mode 100644 index 00000000000..6201a677273 --- /dev/null +++ b/frontend/app/work_packages/services/work-package-attachments-service.js @@ -0,0 +1,97 @@ +//-- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +//++ + +module.exports = function(Upload, PathHelper, I18n, NotificationsService, $q, $timeout, $http) { + + var upload = function(workPackage, files) { + var uploadPath = workPackage.links.addAttachment.url(); + // for file in files build some promises, create a notification per WP, + // notify the noticiation (wat?) about progress + var uploads = _.map(files, function(file) { + var options = { + url: uploadPath, + fields: { + metadata: { + fileName: file.name, + description: file.description + } + }, + file: file + }; + return Upload.upload(options); + }); + + // notify the user + var message = I18n.t('js.label_upload_notification', { + id: workPackage.props.id, + subject: workPackage.props.subject + }); + + var notification = NotificationsService.addWorkPackageUpload(message, uploads); + var allUploadsDone = $q.defer(); + $q.all(uploads).then(function() { + $timeout(function() { // let the notification linger for a bit + NotificationsService.remove(notification); + allUploadsDone.resolve(); + }, 700); + }, function(err) { + allUploadsDone.reject(err); + }); + return allUploadsDone.promise; + }, + load = function(workPackage) { + var path = workPackage.links.attachments.url(), + attachments = $q.defer(); + $http.get(path).success(function(response) { + attachments.resolve(response._embedded.elements); + }).error(function(err) { + attachments.reject(err); + }); + return attachments.promise; + }, + remove = function(fileOrAttachment) { + var removal = $q.defer(); + if (angular.isObject(fileOrAttachment._links)) { + var path = fileOrAttachment._links.self.href; + $http.delete(path).success(function() { + removal.resolve(fileOrAttachment); + }).error(function(err) { + removal.reject(err); + }); + } else { + removal.resolve(fileOrAttachment); + } + return removal.promise; + }; + + return { + upload: upload, + remove: remove, + load: load + }; +}; diff --git a/frontend/app/work_packages/tabs/user-activity-directive.js b/frontend/app/work_packages/tabs/user-activity-directive.js index 953807bb9ae..6927f7faacb 100644 --- a/frontend/app/work_packages/tabs/user-activity-directive.js +++ b/frontend/app/work_packages/tabs/user-activity-directive.js @@ -45,6 +45,7 @@ module.exports = function($uiViewScroll, workPackage: '=', activity: '=', activityNo: '=', + isInitial: '=', inputElementId: '=' }, link: function(scope, element, attrs, exclusiveEditController) { diff --git a/frontend/bower.json b/frontend/bower.json index 4e8365ab731..ba0b3f0ba5a 100644 --- a/frontend/bower.json +++ b/frontend/bower.json @@ -27,7 +27,8 @@ "foundation-apps": "1.1.0", "bourbon": "~4.2.1", "ui-select": "0xf013/ui-select#c7bef79e24cbeab977635c7a94d8f9504d4ee2e2", - "mousetrap": "~1.4.6" + "mousetrap": "~1.4.6", + "ng-file-upload": "~5.0.9" }, "devDependencies": { "mocha": "~1.14.0", diff --git a/frontend/karma.conf.js b/frontend/karma.conf.js index 4b9962d00ea..4c5c5bab613 100644 --- a/frontend/karma.conf.js +++ b/frontend/karma.conf.js @@ -84,9 +84,13 @@ module.exports = function(config) { // preprocess matching files before serving them to the browser // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor preprocessors: { + '/templates/**/*.html': ['ng-html2js'], '../app/assets/javascripts/*.js': ['coverage'], 'app/**/*.js': ['webpack'] // coverage disabled }, + ngHtml2JsPreprocessor: { + module: 'openproject.templates' + }, // test results reporter to use diff --git a/frontend/package.json b/frontend/package.json index cb76f8af3c7..b417b0eb554 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "karma-firefox-launcher": "~0.1.3", "karma-junit-reporter": "~0.2.2", "karma-mocha": "~0.1.3", + "karma-ng-html2js-preprocessor": "^0.1.2", "karma-phantomjs-launcher": "~0.1.4", "karma-webpack": "^1.5.0", "mocha": "~1.18.2", diff --git a/frontend/tests/integration/specs/work-packages/details-pane/details-pane-show-hide-accessibility-spec.js b/frontend/tests/integration/specs/work-packages/details-pane/details-pane-show-hide-accessibility-spec.js index 08795ae8ae2..74039fc725c 100644 --- a/frontend/tests/integration/specs/work-packages/details-pane/details-pane-show-hide-accessibility-spec.js +++ b/frontend/tests/integration/specs/work-packages/details-pane/details-pane-show-hide-accessibility-spec.js @@ -38,12 +38,20 @@ describe('OpenProject', function () { describe('...', function () { beforeEach(function () { $('.attributes-group.ng-scope:nth-child(1) .inplace-edit--read-value').click(); + //element(by.css('.attributes-group.ng-scope:nth-child(1) textarea')); + + // tab through all the elements in the inplace edit control + // Save + browser.actions().sendKeys(protractor.Key.TAB).perform(); + // Save and send + browser.actions().sendKeys(protractor.Key.TAB).perform(); + // Cancel + browser.actions().sendKeys(protractor.Key.TAB).perform(); }); + it('show all / hide all should be accessible in one tab', function () { - var editableTextarea = element(by.css('.attributes-group.ng-scope:nth-child(1) textarea')); - editableTextarea.sendKeys(protractor.Key.ESCAPE); - browser.actions().sendKeys(protractor.Key.TAB).perform(); browser.actions().sendKeys(protractor.Key.TAB).perform(); + return expect(browser.driver.switchTo().activeElement().getText()) .to.eventually.equal('Show all'); }); diff --git a/frontend/tests/integration/specs/work-packages/details-pane/details-pane-spec.js b/frontend/tests/integration/specs/work-packages/details-pane/details-pane-spec.js index af516ae60ad..e96aece7bf3 100644 --- a/frontend/tests/integration/specs/work-packages/details-pane/details-pane-spec.js +++ b/frontend/tests/integration/specs/work-packages/details-pane/details-pane-spec.js @@ -72,15 +72,15 @@ describe('OpenProject', function() { it('should render the last 3 activites', function() { expect( $('ul li:nth-child(1) div.comments-number').getText() - ).to.eventually.equal('#3'); + ).to.eventually.equal('#4'); expect( $('ul li:nth-child(2) div.comments-number').getText() - ).to.eventually.equal('#2'); + ).to.eventually.equal('#3'); expect( $('ul li:nth-child(3) div.comments-number').getText() - ).to.eventually.equal('#1'); + ).to.eventually.equal('#2'); }); it('should contain the activities details', function() { diff --git a/frontend/tests/integration/specs/work-packages/work-packages-spec.js b/frontend/tests/integration/specs/work-packages/work-packages-spec.js index d641f444c9a..f7a23e90399 100644 --- a/frontend/tests/integration/specs/work-packages/work-packages-spec.js +++ b/frontend/tests/integration/specs/work-packages/work-packages-spec.js @@ -34,30 +34,25 @@ describe('OpenProject', function() { beforeEach(function() { page.get(); - browser.waitForAngular(); }); it('should show work packages title', function() { - page.get(); - expect(page.getSelectableTitle().getText()).to.eventually.equal('Work packages'); }); - it('should show work packages', function() { - page.get(); + it('should show the default column headers', function() { + var expected = ['', //note that there is hidden text + 'ID', + 'TYPE', + 'STATUS', + 'SUBJECT', + 'ASSIGNEE']; - page.getTableHeaders().map(function(heading) { - return heading.getText(); - }).then(function(headingTexts) { - var expected = ['', - 'ID', - 'TYPE', - 'STATUS', - 'SUBJECT', - 'ASSIGNEE']; - - for (var i = 0; i < expected.length; i++) { - expect(headingTexts[i]).to.equal(expected[i]); + // the $$('a') should be unnecessary but it was added to prevent flickering + // runs caused by .hidden-for-sighted elements being visible when they shouldn't. + page.getTableHeaders().$$('a').then(function(headings) { + for (var i = 0; i < headings.length; i++) { + expect(headings[i].getText()).to.eventually.equal(expected[i]); } }); }); diff --git a/frontend/tests/unit/tests/directives/components/notification-box-directive-test.js b/frontend/tests/unit/tests/directives/components/notification-box-directive-test.js new file mode 100644 index 00000000000..3d83ff641d8 --- /dev/null +++ b/frontend/tests/unit/tests/directives/components/notification-box-directive-test.js @@ -0,0 +1,63 @@ +//-- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +/* jshint expr: true */ +//++ + +describe('NotificationBoxDirective', function() { + 'use strict'; + var $compile, $rootScope; + + beforeEach(module('openproject.uiComponents')); + beforeEach(module('openproject.templates')); // see karmaConfig + + beforeEach(inject(function(_$compile_, _$rootScope_) { + $compile = _$compile_; + $rootScope = _$rootScope_; + })); + + it('should need a content to properly work', function() { + expect(function() { + $compile('')($rootScope); + $rootScope.$digest(); + }).to.throw; + }); + + it('should render with content set', function() { + $rootScope.warning = { message: 'warning!' }; + var element = $compile('')($rootScope); + $rootScope.$digest(); + expect(element.html()).to.contain('warning!'); + }); + + it('should render with the appropiate type', function() { + $rootScope.error = { message: 'error!', type: 'error' }; + var element = $compile('')($rootScope); + $rootScope.$digest(); + expect(element.html()).to.contain('-error'); + }); + +}); diff --git a/frontend/tests/unit/tests/directives/components/notifications-directive-test.js b/frontend/tests/unit/tests/directives/components/notifications-directive-test.js new file mode 100644 index 00000000000..4b46c04c699 --- /dev/null +++ b/frontend/tests/unit/tests/directives/components/notifications-directive-test.js @@ -0,0 +1,71 @@ +//-- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +/* jshint expr: true */ +//++ + +describe('NotificationsDirective', function() { + 'use strict'; + var $compile, $rootScope; + + beforeEach(module('openproject.uiComponents')); + beforeEach(module('openproject.templates')); // see karmaConfig + + beforeEach(inject(function(_$compile_, _$rootScope_) { + $compile = _$compile_; + $rootScope = _$rootScope_; + })); + + it('should replace the notifications element with the notifications frame', function() { + + var element = $compile('')($rootScope); + + $rootScope.$digest(); + + expect(element.html()).to.include('
    '); + }); + + context('w/ notifications present', function() { + var notification = { message: 'message' }; + + it('should be able to receive notification via $broadcast', function() { + var element = $compile('')($rootScope); + $rootScope.$digest(); + $rootScope.$broadcast('notification.add', notification); + expect(element.scope().stack).to.contain(notification); + }); + + it('should remove notifications when called for', function() { + var element = $compile('')($rootScope); + $rootScope.$digest(); + expect(element.scope().stack).to.be.empty; + $rootScope.$broadcast('notification.add', notification); + expect(element.scope().stack).to.contain(notification); + $rootScope.$broadcast('notification.remove', notification); + expect(element.scope().stack).to.be.empty; + }); + }); +}); diff --git a/frontend/tests/unit/tests/services/conversion-service-test.js b/frontend/tests/unit/tests/services/conversion-service-test.js new file mode 100644 index 00000000000..d6687d3fbc9 --- /dev/null +++ b/frontend/tests/unit/tests/services/conversion-service-test.js @@ -0,0 +1,62 @@ +//-- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +//++ + +describe('ConversionService', function() { + 'use strict'; + var ConversionService; + + beforeEach(module('openproject.services')); + + beforeEach(inject(function(_ConversionService_){ + ConversionService = _ConversionService_; + })); + + it('be able to turn bytes into KiloBytes', function() { + var kiloBytes = ConversionService.kilobytes(1000); + expect(kiloBytes).to.eql(1); + }); + + it('be able to turn bytes into MegaBytes', function() { + var megabytes = ConversionService.megabytes(1000000); + expect(megabytes).to.eql(1); + }); + + it('should dynamically convert bytes into Mega- and Kilobytes', function() { + var result = ConversionService.fileSize(1000000); + expect(result).to.eql('1MB'); + + result = ConversionService.fileSize(1000); + expect(result).to.eql('1kB'); + + result = ConversionService.fileSize(1234); + expect(result).to.eql('1.2kB'); + + result = ConversionService.fileSize(1874234); + expect(result).to.eql('1.9MB'); + }); +}); diff --git a/frontend/tests/unit/tests/services/notifications-service-test.js b/frontend/tests/unit/tests/services/notifications-service-test.js new file mode 100644 index 00000000000..f8de32619c4 --- /dev/null +++ b/frontend/tests/unit/tests/services/notifications-service-test.js @@ -0,0 +1,80 @@ +//-- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +//++ + +describe('NotificationsService', function() { + 'use strict'; + var NotificationsService; + + beforeEach(module('openproject.services')); + + beforeEach(inject(function(_NotificationsService_){ + NotificationsService = _NotificationsService_; + })); + + it('should be able to create notifications', function() { + var notification = NotificationsService.add('message'); + + expect(notification).to.eql({ message: 'message' }); + }); + + it('should be able to create warnings', function() { + var notification = NotificationsService.addWarning('warning!'); + + expect(notification).to.eql({ message: 'warning!', type: 'warning' }); + }); + + it('should throw an Error if trying to create an error without errors', function() { + expect(function() { + NotificationsService.addError('error!'); + }).to.throw(Error); + }); + + it('should throw an Error if trying to create an upload without uploads', function() { + expect(function() { + NotificationsService.addWorkPackageUpload('themUploads'); + }).to.throw(Error); + }); + + it('should be able to create error messages with errors', function() { + var notification = NotificationsService.addError('a super cereal error', ['fooo', 'baarr']); + expect(notification).to.eql({ + message: 'a super cereal error', + errors: ['fooo', 'baarr'], + type: 'error' + }); + }); + + it('should be able to create error messages with errors', function() { + var notification = NotificationsService.addWorkPackageUpload('uploading...', [0, 1, 2]); + expect(notification).to.eql({ + message: 'uploading...', + type: 'upload', + uploads: [0, 1, 2] + }); + }); +}); diff --git a/frontend/tests/unit/tests/work_packages/services/work-package-attachments-service-test.js b/frontend/tests/unit/tests/work_packages/services/work-package-attachments-service-test.js new file mode 100644 index 00000000000..dddb5d94ec1 --- /dev/null +++ b/frontend/tests/unit/tests/work_packages/services/work-package-attachments-service-test.js @@ -0,0 +1,127 @@ +//-- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +//++ +/* jshint expr: true */ +/* globals WebKitBlobBuilder */ + +describe('workPackageAttachmentsService', function() { + 'use strict'; + var WorkPackageAttachmentsService, $httpBackend; + + // mock me a work package + // TODO: remove that hyperagent.js nonsense asap + var workPackage = { + props: { + id: 1 + }, + links: { + attachments: { + url: function() { + return '/api/v3/work_packages/1/attachments'; + } + }, + addAttachment: { + url: function() { + return '/api/v3/work_packages/1/attachments'; + } + } + } + }; + + // mock me an attachment + var attachment = { + _links: { + self: { + href: '/attachments/1234' + } + } + }; + + beforeEach(module('openproject.workPackages')); + + beforeEach(inject(function(_WorkPackageAttachmentsService_, _$httpBackend_){ + WorkPackageAttachmentsService = _WorkPackageAttachmentsService_; + $httpBackend = _$httpBackend_; + })); + + afterEach(function() { + $httpBackend.verifyNoOutstandingRequest(); + $httpBackend.verifyNoOutstandingExpectation(); + }); + + describe('loading attachments', function() { + beforeEach(function() { + $httpBackend.expectGET('/api/v3/work_packages/1/attachments').respond({ + _embedded: { + elements: [1,2,3] + } + }); + }); + + it('should retrieve attachments for a given work pacakge', function () { + WorkPackageAttachmentsService.load(workPackage).then(function(result) { + expect(result).to.eql([1,2,3]); + }); + $httpBackend.flush(); + }); + }); + + describe('creating an attachment', function() { + beforeEach(function() { + $httpBackend.expectPOST('/api/v3/work_packages/1/attachments').respond({}); + }); + + function createFiles() { + var blob; + try { + var builder = new WebKitBlobBuilder(); + builder.append(['I am a TestFile for WebKit browsers']); + blob = builder.getBlob(); + } catch(Error) { + blob = new Blob(['I am a testfile']); + } + return [blob]; + } + + it('should create an attachment for a given work package', function () { + var files = createFiles(); + WorkPackageAttachmentsService.upload(workPackage, files); + $httpBackend.flush(); + }); + }); + + describe('deleting an attachment', function() { + beforeEach(function() { + $httpBackend.expectDELETE('/attachments/1234').respond({}); + }); + + it('should remove an attachment', function () { + WorkPackageAttachmentsService.remove(attachment); + $httpBackend.flush(); + }); + }); +}); diff --git a/frontend/tests/unit/tests/work_packages/tabs/user-activity-directive-test.js b/frontend/tests/unit/tests/work_packages/tabs/user-activity-directive-test.js index 44e7f30b22e..7524139e499 100644 --- a/frontend/tests/unit/tests/work_packages/tabs/user-activity-directive-test.js +++ b/frontend/tests/unit/tests/work_packages/tabs/user-activity-directive-test.js @@ -49,7 +49,7 @@ describe('userActivity Directive', function() { beforeEach(inject(function($rootScope, $compile, $uiViewScroll, $timeout, $location, I18n, PathHelper, ActivityService, UsersHelper) { var html; - html = '
    '; + html = '
    '; rootScope = $rootScope; scope = $rootScope.$new(); @@ -107,8 +107,9 @@ describe('userActivity Directive', function() { html: 'Type changed' }, ] - }, + } }; + scope.isInitial = false; compile(); }); @@ -164,6 +165,18 @@ describe('userActivity Directive', function() { expect(detail1).to.eq(scope.activity.props.details[0].html); expect(detail2).to.eq(scope.activity.props.details[1].html); }); + + context('for initial journal', function() { + beforeEach(function() { + scope.isInitial = true; + compile(); + }); + it('should not render activity details', function() { + var listFinder = element.find('ul.work-package-details-activities-messages'); + + expect(listFinder).to.have.length(0); + }); + }); }); }); }); diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index ef06d1219b2..f6369c8dad6 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -122,7 +122,9 @@ module.exports = { 'angular-context-menu': 'angular-context-menu/dist/angular-context-menu.js', 'mousetrap': 'mousetrap/mousetrap.js', 'hyperagent': 'hyperagent/dist/hyperagent', - 'openproject-ui_components': 'openproject-ui_components/app/assets/javascripts/angular/ui-components-app' + 'openproject-ui_components': + 'openproject-ui_components/app/assets/javascripts/angular/ui-components-app', + 'ngFileUpload': 'ng-file-upload/ng-file-upload' }, pluginAliases) }, diff --git a/lib/api/decorators/single.rb b/lib/api/decorators/single.rb index 6a88f13a105..261b10be839 100644 --- a/lib/api/decorators/single.rb +++ b/lib/api/decorators/single.rb @@ -54,10 +54,10 @@ module API exec_context: :decorator, render_nil: false - def self.self_link(path: nil, title_getter: -> (*) { represented.name }) + def self.self_link(path: nil, id_attribute: :id, title_getter: -> (*) { represented.name }) link :self do path = _type.underscore unless path - link_object = { href: api_v3_paths.send(path, represented.id) } + link_object = { href: api_v3_paths.send(path, represented.send(id_attribute)) } title = instance_eval(&title_getter) link_object[:title] = title if title diff --git a/lib/api/v3/activities/activities_api.rb b/lib/api/v3/activities/activities_api.rb index 9aa07924235..975879c8ce8 100644 --- a/lib/api/v3/activities/activities_api.rb +++ b/lib/api/v3/activities/activities_api.rb @@ -38,42 +38,40 @@ module API end route_param :id do before do - @activity = Journal.find(params[:id]) - @representer = ActivityRepresenter.new(@activity, current_user: current_user) - end + @activity = Journal::AggregatedJournal.with_notes_id(params[:id]) + raise API::Errors::NotFound unless @activity - get do authorize(:view_project, context: @activity.journable.project) - @representer end helpers do def save_activity(activity) - if activity.save - representer = ActivityRepresenter.new(activity) - - representer - else + unless activity.save fail ::API::Errors::ErrorBase.create_and_merge_errors(activity.errors) end end def authorize_edit_own(activity) - return authorize({ controller: :journals, action: :edit }, context: @activity.journable.project) - raise API::Errors::Unauthorized.new(current_user) unless activity.editable_by?(current_user) + authorize({ controller: :journals, action: :edit }, + context: activity.journable.project) end end + get do + ActivityRepresenter.new(@activity, current_user: current_user) + end + params do requires :comment, type: String end patch do - authorize_edit_own(@activity) + editable_activity = Journal.find(@activity.notes_id) + authorize_edit_own(editable_activity) + editable_activity.notes = params[:comment] + save_activity(editable_activity) - @activity.notes = params[:comment] - - save_activity(@activity) + ActivityRepresenter.new(@activity.reloaded, current_user: current_user) end end end diff --git a/lib/api/v3/activities/activities_by_work_package_api.rb b/lib/api/v3/activities/activities_by_work_package_api.rb new file mode 100644 index 00000000000..374a5c51e6d --- /dev/null +++ b/lib/api/v3/activities/activities_by_work_package_api.rb @@ -0,0 +1,65 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'api/v3/activities/activity_representer' + +module API + module V3 + module Activities + class ActivitiesByWorkPackageAPI < ::API::OpenProjectAPI + resource :activities do + helpers do + def save_work_package(work_package) + if work_package.save + journals = ::Journal::AggregatedJournal.aggregated_journals( + journable: work_package) + Activities::ActivityRepresenter.new(journals.last, current_user: current_user) + else + fail ::API::Errors::ErrorBase.create_and_merge_errors(work_package.errors) + end + end + end + + params do + requires :comment, type: String + end + post do + authorize({ controller: :journals, action: :new }, + context: @work_package.project) do + raise ::API::Errors::NotFound.new + end + + @work_package.journal_notes = params[:comment] + + save_work_package(@work_package) + end + end + end + end + end +end diff --git a/lib/api/v3/activities/activity_representer.rb b/lib/api/v3/activities/activity_representer.rb index 8438e298754..52617814409 100644 --- a/lib/api/v3/activities/activity_representer.rb +++ b/lib/api/v3/activities/activity_representer.rb @@ -36,6 +36,7 @@ module API include API::V3::Utilities self_link path: :activity, + id_attribute: :notes_id, title_getter: -> (*) { nil } link :workPackage do @@ -47,20 +48,20 @@ module API link :user do { - href: api_v3_paths.user(represented.user.id), - title: "#{represented.user.name} - #{represented.user.login}" + href: api_v3_paths.user(represented.user.id) } end link :update do { - href: api_v3_paths.activity(represented.id), - method: :patch, - title: "#{represented.id}" + href: api_v3_paths.activity(represented.notes_id), + method: :patch } if current_user_allowed_to_edit? end - property :id, render_nil: true + property :id, + getter: -> (*) { notes_id }, + render_nil: true property :comment, exec_context: :decorator, getter: -> (*) { diff --git a/lib/api/v3/attachments/attachments_by_work_package_api.rb b/lib/api/v3/attachments/attachments_by_work_package_api.rb index f500920f500..5c7287942f0 100644 --- a/lib/api/v3/attachments/attachments_by_work_package_api.rb +++ b/lib/api/v3/attachments/attachments_by_work_package_api.rb @@ -48,16 +48,6 @@ module API metadata end - - def make_attachment(metadata, file) - uploaded_file = OpenProject::Files.build_uploaded_file file[:tempfile], - file[:type], - file_name: metadata.file_name - Attachment.new(file: uploaded_file, - container: @work_package, - description: metadata.description, - author: current_user) - end end get do @@ -79,9 +69,16 @@ module API I18n.t('api_v3.errors.multipart_body_error')) end - attachment = make_attachment(metadata, file) - unless attachment.save - raise ::API::Errors::ErrorBase.create_and_merge_errors(attachment.errors) + uploaded_file = OpenProject::Files.build_uploaded_file file[:tempfile], + file[:type], + file_name: metadata.file_name + + begin + service = AddAttachmentService.new(@work_package, author: current_user) + attachment = service.add_attachment uploaded_file: uploaded_file, + description: metadata.description + rescue ActiveRecord::RecordInvalid => error + raise ::API::Errors::ErrorBase.create_and_merge_errors(error.record.errors) end ::API::V3::Attachments::AttachmentRepresenter.new(attachment) diff --git a/lib/api/v3/repositories/revision_representer.rb b/lib/api/v3/repositories/revision_representer.rb new file mode 100644 index 00000000000..0bde2806825 --- /dev/null +++ b/lib/api/v3/repositories/revision_representer.rb @@ -0,0 +1,77 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +module API + module V3 + module Repositories + class RevisionRepresenter < ::API::Decorators::Single + include API::V3::Utilities + + self_link path: :revision, + title_getter: -> (*) { nil } + + link :project do + { + href: api_v3_paths.project(represented.project.id), + title: represented.project.name + } + end + + link :author do + { + href: api_v3_paths.user(represented.user.id), + title: represented.user.name + } unless represented.user.nil? + end + + property :id + property :identifier + property :author, as: :authorName + property :message, + exec_context: :decorator, + getter: -> (*) { + ::API::Decorators::Formattable.new(represented.comments, + object: represented, + format: 'plain') + }, + render_nil: true + + property :created_at, + exec_context: :decorator, + getter: -> (*) { + datetime_formatter.format_datetime(represented.committed_on) + } + + def _type + 'Revision' + end + end + end + end +end diff --git a/lib/api/v3/repositories/revisions_api.rb b/lib/api/v3/repositories/revisions_api.rb new file mode 100644 index 00000000000..f53c5313855 --- /dev/null +++ b/lib/api/v3/repositories/revisions_api.rb @@ -0,0 +1,61 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ +module API + module V3 + module Repositories + class RevisionsAPI < ::API::OpenProjectAPI + resources :revisions do + params do + requires :id, desc: 'Revision id' + end + route_param :id do + helpers do + attr_reader :revision + + def revision_representer + RevisionRepresenter.new(revision) + end + end + + before do + @revision = Changeset.find(params[:id]) + + authorize(:view_changesets, context: revision.project) do + raise API::Errors::NotFound.new + end + end + + get do + revision_representer + end + end + end + end + end + end +end diff --git a/lib/api/v3/repositories/revisions_by_work_package_api.rb b/lib/api/v3/repositories/revisions_by_work_package_api.rb new file mode 100644 index 00000000000..492dbf213e1 --- /dev/null +++ b/lib/api/v3/repositories/revisions_by_work_package_api.rb @@ -0,0 +1,52 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'api/v3/repositories/revisions_collection_representer' + +module API + module V3 + module Repositories + class RevisionsByWorkPackageAPI < ::API::OpenProjectAPI + resources :revisions do + before do + authorize(:view_changesets, context: work_package.project) + end + + get do + self_path = api_v3_paths.work_package_revisions(work_package.id) + + revisions = work_package.changesets + RevisionsCollectionRepresenter.new(revisions, + revisions.count, + self_path) + end + end + end + end + end +end diff --git a/app/workers/deliver_work_package_updated_job.rb b/lib/api/v3/repositories/revisions_collection_representer.rb similarity index 77% rename from app/workers/deliver_work_package_updated_job.rb rename to lib/api/v3/repositories/revisions_collection_representer.rb index f6912591e3f..104d0bc8c4c 100644 --- a/app/workers/deliver_work_package_updated_job.rb +++ b/lib/api/v3/repositories/revisions_collection_representer.rb @@ -27,19 +27,12 @@ # See doc/COPYRIGHT.rdoc for more details. #++ -class DeliverWorkPackageUpdatedJob < MailNotificationJob - def initialize(recipient_id, journal_id, author_id) - super(recipient_id, author_id) - @journal_id = journal_id - end - - private - - def notification_mail - @notification_mail ||= UserMailer.work_package_updated(recipient, journal, author) - end - - def journal - @journal ||= Journal.find(@journal_id) +module API + module V3 + module Repositories + class RevisionsCollectionRepresenter < ::API::Decorators::Collection + element_decorator ::API::V3::Repositories::RevisionRepresenter + end + end end end diff --git a/lib/api/v3/root.rb b/lib/api/v3/root.rb index 3f389ea47ed..a437a8b3611 100644 --- a/lib/api/v3/root.rb +++ b/lib/api/v3/root.rb @@ -42,6 +42,7 @@ module API mount ::API::V3::Projects::ProjectsAPI mount ::API::V3::Queries::QueriesAPI mount ::API::V3::Render::RenderAPI + mount ::API::V3::Repositories::RevisionsAPI mount ::API::V3::Statuses::StatusesAPI mount ::API::V3::StringObjects::StringObjectsAPI mount ::API::V3::Types::TypesAPI diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index af52b2f81f9..49f37f4dbd3 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -34,6 +34,8 @@ module API include API::Utilities::UrlHelper class ApiV3Path + extend API::Utilities::UrlHelper + def self.root "#{root_path}api/v3" end @@ -47,7 +49,7 @@ module API end def self.attachment_download(id) - Rails.application.routes.url_helpers.attachment_path(id) + attachment_path(id) end def self.attachments_by_work_package(id) @@ -114,6 +116,10 @@ module API "#{root}/relations/#{id}" end + def self.revision(id) + "#{root}/revisions/#{id}" + end + def self.render_markup(format: nil, link: nil) format = format || Setting.text_formatting format = 'plain' if format == '' # Setting will return '' for plain @@ -200,6 +206,10 @@ module API "#{work_package_relations(work_package_id)}/#{id}" end + def self.work_package_revisions(id) + "#{work_package(id)}/revisions" + end + def self.work_package_schema(project_id, type_id) "#{root}/work_packages/schemas/#{project_id}-#{type_id}" end @@ -211,12 +221,6 @@ module API def self.work_packages_by_project(project_id) "#{project(project_id)}/work_packages" end - - def self.root_path - @@root_path ||= Class.new.tap do |c| - c.extend(::API::V3::Utilities::PathHelper) - end.root_path - end end def api_v3_paths diff --git a/lib/api/v3/work_packages/create_form_api.rb b/lib/api/v3/work_packages/create_form_api.rb index 9b9388a1d8d..c87dd5ec467 100644 --- a/lib/api/v3/work_packages/create_form_api.rb +++ b/lib/api/v3/work_packages/create_form_api.rb @@ -27,6 +27,7 @@ #++ require 'api/v3/work_packages/work_packages_shared_helpers' +require 'api/v3/work_packages/create_contract' module API module V3 @@ -36,8 +37,9 @@ module API helpers ::API::V3::WorkPackages::WorkPackagesSharedHelpers post do + create_contract = ::API::V3::WorkPackages::CreateContract create_work_package_form(create_service.create, - contract_class: CreateContract, + contract_class: create_contract, form_class: CreateFormRepresenter) end end diff --git a/lib/api/v3/work_packages/work_package_representer.rb b/lib/api/v3/work_packages/work_package_representer.rb index afb482d21b4..019f1082128 100644 --- a/lib/api/v3/work_packages/work_package_representer.rb +++ b/lib/api/v3/work_packages/work_package_representer.rb @@ -128,6 +128,12 @@ module API } if current_user_allowed_to(:add_work_package_watchers, context: represented.project) end + link :revisions do + { + href: api_v3_paths.work_package_revisions(represented.id) + } if current_user_allowed_to(:view_changesets, context: represented.project) + end + link :watch do { href: api_v3_paths.work_package_watchers(represented.id), @@ -307,7 +313,7 @@ module API end def activities - represented.journals.map do |activity| + ::Journal::AggregatedJournal.aggregated_journals(journable: represented).map do |activity| ::API::V3::Activities::ActivityRepresenter.new(activity, current_user: current_user) end end diff --git a/lib/api/v3/work_packages/work_packages_api.rb b/lib/api/v3/work_packages/work_packages_api.rb index 1c3a13a2f1a..ddc11b16154 100644 --- a/lib/api/v3/work_packages/work_packages_api.rb +++ b/lib/api/v3/work_packages/work_packages_api.rb @@ -26,7 +26,6 @@ # See doc/COPYRIGHT.rdoc for more details. #++ -require 'api/v3/activities/activity_representer' require 'api/v3/work_packages/work_package_representer' module API @@ -79,36 +78,11 @@ module API end end - resource :activities do - helpers do - def save_work_package(work_package) - if work_package.save - Activities::ActivityRepresenter.new(work_package.journals.last, - current_user: current_user) - else - fail ::API::Errors::ErrorBase.create_and_merge_errors(work_package.errors) - end - end - end - - params do - requires :comment, type: String - end - post do - authorize({ controller: :journals, action: :new }, - context: @work_package.project) do - raise ::API::Errors::NotFound.new - end - - @work_package.journal_notes = params[:comment] - - save_work_package(@work_package) - end - end - mount ::API::V3::WorkPackages::WatchersAPI mount ::API::V3::Relations::RelationsAPI + mount ::API::V3::Activities::ActivitiesByWorkPackageAPI mount ::API::V3::Attachments::AttachmentsByWorkPackageAPI + mount ::API::V3::Repositories::RevisionsByWorkPackageAPI mount ::API::V3::WorkPackages::UpdateFormAPI end diff --git a/lib/api/v3/work_packages/work_packages_by_project_api.rb b/lib/api/v3/work_packages/work_packages_by_project_api.rb index 320e0947a00..b9d4e809aff 100644 --- a/lib/api/v3/work_packages/work_packages_by_project_api.rb +++ b/lib/api/v3/work_packages/work_packages_by_project_api.rb @@ -28,6 +28,7 @@ require 'api/v3/work_packages/work_package_representer' require 'api/v3/work_packages/work_packages_shared_helpers' +require 'api/v3/work_packages/create_contract' module API module V3 @@ -50,10 +51,9 @@ module API write_work_package_attributes work_package - contract = WorkPackages::CreateContract.new(work_package, current_user) + contract = ::API::V3::WorkPackages::CreateContract.new(work_package, current_user) if contract.validate && create_service.save(work_package) work_package.reload - WorkPackages::WorkPackageRepresenter.create(work_package, current_user: current_user) else fail ::API::Errors::ErrorBase.create_and_merge_errors(contract.errors) diff --git a/lib/open_project/configuration.rb b/lib/open_project/configuration.rb index ab817438d16..b61d6450402 100644 --- a/lib/open_project/configuration.rb +++ b/lib/open_project/configuration.rb @@ -43,7 +43,6 @@ module OpenProject 'autologin_cookie_path' => '/', 'autologin_cookie_secure' => false, 'database_cipher_key' => nil, - 'scm_filesystem_path_whitelist' => [], 'scm_git_command' => nil, 'scm_subversion_command' => nil, 'disable_browser_cache' => true, diff --git a/lib/open_project/journal_formatter/diff.rb b/lib/open_project/journal_formatter/diff.rb index 21685c0325d..92867823cf5 100644 --- a/lib/open_project/journal_formatter/diff.rb +++ b/lib/open_project/journal_formatter/diff.rb @@ -69,6 +69,7 @@ class OpenProject::JournalFormatter::Diff < JournalFormatter::Base end def link(key, options) + url_attr = default_attributes(options).merge(controller: '/journals', action: 'diff', id: @journal.id, diff --git a/lib/open_project/scm/adapters.rb b/lib/open_project/scm/adapters.rb new file mode 100644 index 00000000000..041fd57a8c5 --- /dev/null +++ b/lib/open_project/scm/adapters.rb @@ -0,0 +1,131 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ +module OpenProject + module Scm + module Adapters + class Entries < Array + def sort_by_name + sort do |x, y| + if x.kind == y.kind + x.name.to_s <=> y.name.to_s + else + x.kind <=> y.kind + end + end + end + end + + class Info + attr_accessor :root_url, :lastrev + def initialize(attributes = {}) + self.root_url = attributes[:root_url] + self.lastrev = attributes[:lastrev] + end + end + + class Entry + attr_accessor :name, :path, :kind, :size, :lastrev + def initialize(attributes = {}) + [:name, :path, :kind, :size].each do |attr| + send("#{attr}=", attributes[attr]) + end + + self.size = size.to_i if size.present? + self.lastrev = attributes[:lastrev] + end + + def file? + 'file' == kind + end + + def dir? + 'dir' == kind + end + end + + class Revisions < Array + def latest + sort { |x, y| + if x.time.nil? or y.time.nil? + 0 + else + x.time <=> y.time + end + }.last + end + end + + class Revision + attr_accessor :scmid, :name, :author, :time, :message, :paths, :revision, :branch + attr_writer :identifier + + def initialize(attributes = {}) + [:identifier, :scmid, :author, :time, :paths, :revision, :branch].each do |attr| + send("#{attr}=", attributes[attr]) + end + + self.name = attributes[:name].presence || identifier + self.message = attributes[:message].presence || '' + end + + # Returns the identifier of this revision; see also Changeset model + def identifier + (@identifier || revision).to_s + end + + # Returns the readable identifier. + def format_identifier + identifier + end + end + + class Annotate + attr_reader :lines, :revisions + + def initialize + @lines = [] + @revisions = [] + end + + def add_line(line, revision) + @lines << line + @revisions << revision + end + + def content + lines.join("\n") + end + + def empty? + lines.empty? + end + end + end + end +end diff --git a/lib/open_project/scm/adapters/base.rb b/lib/open_project/scm/adapters/base.rb new file mode 100644 index 00000000000..5b0777bcfd3 --- /dev/null +++ b/lib/open_project/scm/adapters/base.rb @@ -0,0 +1,165 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'open_project/scm/adapters' + +module OpenProject + module Scm + module Adapters + class Base + attr_accessor :url, :root_url + + def self.vendor + name.demodulize + end + + def initialize(url, root_url = nil) + self.url = url + self.root_url = root_url + end + + def local? + false + end + + ## + # Overriden by descendants when + # they are able to retrieve current + # storage usage. + def storage_available? + false + end + + def available? + check_availability! + true + rescue Exceptions::ScmError => e + logger.error("Failed to retrieve availability of repository: #{e.message}") + false + end + + def logger + Rails.logger + end + + def vendor + self.class.vendor + end + + def info + nil + end + + # Returns the entry identified by path and revision identifier + # or nil if entry doesn't exist in the repository + def entry(path = nil, identifier = nil) + parts = path.to_s.split(%r{[\/\\]}).select { |n| !n.blank? } + search_entries(parts, identifier) + end + + def search_entries(parts, identifier) + search_path = parts[0..-2].join('/') + search_name = parts[-1] + + if search_path.blank? && search_name.blank? + # Root entry + Entry.new(path: '', kind: 'dir') + else + # Search for the entry in the parent directory + es = entries(search_path, identifier) + es ? es.detect { |e| e.name == search_name } : nil + end + end + + # Returns an Entries collection + # or nil if the given path doesn't exist in the repository + def entries(_path = nil, _identifier = nil) + nil + end + + def branches + nil + end + + def tags + nil + end + + def default_branch + nil + end + + def properties(_path, _identifier = nil) + nil + end + + def revisions(_path = nil, _identifier_from = nil, _identifier_to = nil, _options = {}) + nil + end + + def diff(_path, _identifier_from, _identifier_to = nil) + nil + end + + def cat(_path, _identifier = nil) + nil + end + + def with_leading_slash(path) + path ||= '' + (path[0, 1] != '/') ? "/#{path}" : path + end + + def with_trailling_slash(path) + path ||= '' + (path[-1, 1] == '/') ? path : "#{path}/" + end + + def without_leading_slash(path) + path ||= '' + path.gsub(%r{\A/+}, '') + end + + def without_trailling_slash(path) + path ||= '' + (path[-1, 1] == '/') ? path[0..-2] : path + end + end + + class Info + attr_accessor :root_url, :lastrev + + def initialize(attributes = {}) + self.root_url = attributes[:root_url] if attributes[:root_url] + self.lastrev = attributes[:lastrev] + end + end + end + end +end diff --git a/lib/open_project/scm/adapters/git.rb b/lib/open_project/scm/adapters/git.rb new file mode 100644 index 00000000000..a5d41677bf8 --- /dev/null +++ b/lib/open_project/scm/adapters/git.rb @@ -0,0 +1,395 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require_dependency 'open_project/scm/adapters' + +module OpenProject + module Scm + module Adapters + class Git < Base + include LocalClient + + SCM_GIT_REPORT_LAST_COMMIT = true + + def initialize(url, root_url = nil, _login = nil, _password = nil, path_encoding = nil) + super(url, root_url) + @flag_report_last_commit = SCM_GIT_REPORT_LAST_COMMIT + @path_encoding = path_encoding.presence || 'UTF-8' + end + + def client_command + @client_command ||= self.class.config[:client_command] || 'git' + end + + def client_version + @client_version ||= (git_binary_version || []) + end + + def scm_version_from_command_line + capture_out(%w[--version --no-color]) + end + + def git_binary_version + scm_version = scm_version_from_command_line.dup + if scm_version.respond_to?(:force_encoding) + scm_version.force_encoding('ASCII-8BIT') + end + m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)}) + unless m.nil? + m[2].scan(%r{\d+}).map(&:to_i) + end + end + + ## + # Create a bare repository for the current path + def initialize_bare_git + capture_git(%w[init --bare]) + end + + ## + # Checks the status of this repository and throws unless it can be accessed + # correctly by the adapter. + # + # @raise [ScmUnavailable] raised when repository is unavailable. + def check_availability! + out, = Open3.capture2e(client_command, *build_git_cmd(%w[log -- HEAD])) + raise Exceptions::ScmEmpty if out.include?("fatal: bad default revision 'HEAD'") + + # If it not empty, it should have at least one readable branch. + raise Exceptions::ScmUnavailable unless branches.size > 0 + rescue Exceptions::CommandFailed => e + logger.error("Availability check failed due to failed Git command: #{e.message}") + raise Exceptions::ScmUnavailable + end + + def info + Info.new(root_url: url, lastrev: lastrev('', nil)) + end + + def branches + return @branches if @branches + @branches = [] + cmd_args = %w|branch --no-color| + popen3(cmd_args) do |io| + io.each_line do |line| + @branches << line.match('\s*\*?\s*(.*)$')[1] + end + end + @branches.sort! + end + + def tags + return @tags if @tags + cmd_args = %w|tag| + @tags = capture_git(cmd_args).lines.sort!.map(&:strip) + end + + def default_branch + bras = branches + return nil if bras.nil? + bras.include?('master') ? 'master' : bras.first + end + + def entries(path, identifier = nil) + entries = Entries.new + path = scm_encode(@path_encoding, 'UTF-8', path) + args = %w|ls-tree -l| + args << "HEAD:#{path}" if identifier.nil? + args << "#{identifier}:#{path}" if identifier + + parse_by_line(args, binmode: true) do |line| + e = parse_entry(line, path, identifier) + entries << e unless entries.detect { |entry| entry.name == e.name } + end + + entries.sort_by_name + end + + def parse_entry(line, path, identifier) + if line.chomp =~ /^\d+\s+(\w+)\s+([0-9a-f]{40})\s+([0-9-]+)\t(.+)$/ + type = $1 + size = $3 + name = $4.force_encoding(@path_encoding) + path = encode_full_path(name, path || '') + + Entry.new( + name: scm_encode('UTF-8', @path_encoding, name), + path: path, + kind: (type == 'tree') ? 'dir' : 'file', + size: (type == 'tree') ? nil : size, + lastrev: @flag_report_last_commit ? lastrev(path, identifier) : Revision.new + ) + end + end + + def encode_full_path(name, path) + full_path = path.empty? ? name : "#{path}/#{name}" + scm_encode('UTF-8', @path_encoding, full_path) + end + + def lastrev(path, rev) + return nil if path.nil? + args = %w|log --no-color --encoding=UTF-8 --date=iso --pretty=fuller --no-merges -n 1| + args << rev if rev + args << '--' << path unless path.empty? + lines = capture_git(args).lines + begin + build_lastrev(lines) + rescue NoMethodError + logger.error("The revision '#{path}' has a wrong format") + return nil + end + end + + def build_lastrev(lines) + id = lines[0].split[1] + author = lines[1].match('Author:\s+(.*)$')[1] + time = Time.parse(lines[4].match('CommitDate:\s+(.*)$')[1]) + + Revision.new( + identifier: id, + scmid: id, + author: author, + time: time, + message: nil, + paths: nil + ) + end + + def revisions(path, identifier_from, identifier_to, options = {}) + revisions = Revisions.new + args = build_revision_args(path, identifier_from, identifier_to, options) + + files = [] + changeset = {} + parsing_descr = 0 # 0: not parsing desc or files, 1: parsing desc, 2: parsing files + parse_by_line(args, binmode: true) do |line| + if line =~ /^commit ([0-9a-f]{40})$/ + key = 'commit' + value = $1 + if parsing_descr == 1 || parsing_descr == 2 + parsing_descr = 0 + revision = Revision.new( + identifier: changeset[:commit], + scmid: changeset[:commit], + author: changeset[:author], + time: Time.parse(changeset[:date]), + message: changeset[:description], + paths: files + ) + if block_given? + yield revision + else + revisions << revision + end + changeset = {} + files = [] + end + changeset[:commit] = $1 + elsif (parsing_descr == 0) && line =~ /^(\w+):\s*(.*)$/ + key = $1 + value = $2 + if key == 'Author' + changeset[:author] = value + elsif key == 'CommitDate' + changeset[:date] = value + end + elsif (parsing_descr == 0) && line.chomp.to_s == '' + parsing_descr = 1 + changeset[:description] = '' + elsif (parsing_descr == 1 || parsing_descr == 2) && + (line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\t(.+)$/) + + parsing_descr = 2 + fileaction = $1 + filepath = $2 + p = scm_encode('UTF-8', @path_encoding, filepath) + files << { action: fileaction, path: p } + elsif (parsing_descr == 1 || parsing_descr == 2) && + (line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\d+\s+(\S+)\t(.+)$/) + + parsing_descr = 2 + fileaction = $1 + filepath = $3 + p = scm_encode('UTF-8', @path_encoding, filepath) + files << { action: fileaction, path: p } + elsif (parsing_descr == 1) && line.chomp.to_s == '' + parsing_descr = 2 + elsif parsing_descr == 1 + changeset[:description] << line[4..-1] + end + end + + if changeset[:commit] + revision = Revision.new( + identifier: changeset[:commit], + scmid: changeset[:commit], + author: changeset[:author], + time: Time.parse(changeset[:date]), + message: changeset[:description], + paths: files + ) + + if block_given? + yield revision + else + revisions << revision + end + end + + revisions + end + + def build_revision_args(path, identifier_from, identifier_to, options) + args = %w|log --no-color --encoding=UTF-8 --raw --date=iso --pretty=fuller| + args << '--reverse' if options[:reverse] + args << '--all' if options[:all] + args << '-n' << "#{options[:limit].to_i}" if options[:limit] + from_to = '' + from_to << "#{identifier_from}.." if identifier_from + from_to << "#{identifier_to}" if identifier_to + args << from_to if from_to.present? + args << "--since=#{options[:since].strftime('%Y-%m-%d %H:%M:%S')}" if options[:since] + args << '--' << scm_encode(@path_encoding, 'UTF-8', path) if path && !path.empty? + + args + end + + def diff(path, identifier_from, identifier_to = nil) + args = [] + if identifier_to + args << 'diff' << '--no-color' << identifier_to << identifier_from + else + args << 'show' << '--no-color' << identifier_from + end + args << '--' << scm_encode(@path_encoding, 'UTF-8', path) unless path.empty? + capture_git(args).lines.map(&:chomp) + rescue Exceptions::CommandFailed + nil + end + + def annotate(path, identifier = nil) + identifier = 'HEAD' if identifier.blank? + args = %w|blame --encoding=UTF-8| + args << '-p' << identifier << '--' << scm_encode(@path_encoding, 'UTF-8', path) + blame = Annotate.new + content = capture_git(args, binmode: true) + + # Deny to parse large binary files + # Quick test for null bytes, this may not match all files, + # but should be a reasonable workaround + return nil if content.dup.force_encoding('BINARY').count("\x00") > 0 + + identifier = '' + # git shows commit author on the first occurrence only + authors_by_commit = {} + content.scrub.split("\n").each do |line| + if line =~ /^([0-9a-f]{39,40})\s.*/ + identifier = $1 + elsif line =~ /^author (.+)/ + authors_by_commit[identifier] = $1.strip + elsif line =~ /^\t(.*)/ + blame.add_line( + $1, + Revision.new( + identifier: identifier, + author: authors_by_commit[identifier])) + identifier = '' + end + end + blame + end + + def cat(path, identifier = nil) + if identifier.nil? + identifier = 'HEAD' + end + args = %w|show --no-color| + args << "#{identifier}:#{scm_encode(@path_encoding, 'UTF-8', path)}" + capture_git(args, binmode: true) + end + + class Revision < OpenProject::Scm::Adapters::Revision + # Returns the readable identifier + def format_identifier + identifier[0, 8] + end + end + + private + + ## + # Builds the full git arguments from the parameters + # and return the executed stdout as a string + def capture_git(args, opt = {}) + cmd = build_git_cmd(args) + capture_out(cmd, opt) + end + + ## + # Builds the full git arguments from the parameters + # and calls the given block with in, out, err, thread + # from +Open3#popen3+. + def popen3(args, opt = {}, &block) + cmd = build_git_cmd(args) + super(cmd, opt) do |_stdin, stdout, _stderr, wait_thr| + block.call(stdout) + + process = wait_thr.value + if process.exitstatus != 0 + raise Exceptions::CommandFailed.new( + 'git', + "git exited with non-zero status: #{process.exitstatus}" + ) + end + end + end + + ## + # Runs the given arguments through git + # and processes the result line by line. + # + def parse_by_line(cmd, opts = {}, &block) + popen3(cmd) do |io| + io.binmode if opts[:binmode] + io.each_line &block + end + end + + def build_git_cmd(args) + if client_version_above?([1, 7, 2]) + args.unshift('-c', 'core.quotepath=false') + end + + args.unshift('--git-dir', (root_url.presence || url)) + end + end + end + end +end diff --git a/lib/open_project/scm/adapters/local_client.rb b/lib/open_project/scm/adapters/local_client.rb new file mode 100644 index 00000000000..5364405e5ba --- /dev/null +++ b/lib/open_project/scm/adapters/local_client.rb @@ -0,0 +1,239 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'open3' +module OpenProject + module Scm + module Adapters + module LocalClient + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + ## + # Reads the configuration for this strategy from OpenProject's `configuration.yml`. + def config + ['scm', vendor].inject(OpenProject::Configuration) do |acc, key| + HashWithIndifferentAccess.new acc[key] + end + end + end + + ## + # Determines local capabilities for SCM creation. + # Overridden by including classes when SCM may be remote. + def local? + true + end + + ## + # Determines whether this repository is eligible + # to count storage. + def storage_available? + local? && File.directory?(local_repository_path) + end + + ## + # Counts the repository storage requirement immediately + # or raises an exception if this is impossible for the current repository. + def count_repository! + if storage_available? + count_required_storage + else + raise Exceptions::ScmError.new I18n.t('repositories.storage.not_available') + end + end + + ## + # Retrieve the local FS path + # of this repository. + # + # Overriden by some vendors, as not + # all vendors have a path root_url. + # (e.g., subversion uses file:// URLs) + def local_repository_path + root_url + end + + ## + # Reads the configuration for this strategy from OpenProject's `configuration.yml`. + def config + scm_config = OpenProject::Configuration + ['scm', vendor].inject(scm_config) do |acc, key| + HashWithIndifferentAccess.new acc[key] + end + end + + ## + # client executable command + def client_command + '' + end + + def client_available + !client_version.empty? + end + + ## + # Returns the version of the scm client + # Eg: [1, 5, 0] or [] if unknown + def client_version + [] + end + + ## + # Returns the version string of the scm client + # Eg: '1.5.0' or 'Unknown version' if unknown + def client_version_string + v = client_version || 'Unknown version' + v.is_a?(Array) ? v.join('.') : v.to_s + end + + ## + # Returns true if the current client version is above + # or equals the given one + # If option is :unknown is set to true, it will return + # true if the client version is unknown + def client_version_above?(v, options = {}) + ((client_version <=> v) >= 0) || (client_version.empty? && options[:unknown]) + end + + def shell_quote(str) + Shellwords.escape(str) + end + + def supports_cat? + true + end + + def supports_annotate? + respond_to?('annotate') + end + + def target(path = '') + base = path.match(/\A\//) ? root_url : url + shell_quote("#{base}/#{path}".gsub(/[?<>\*]/, '')) + end + + ## + # Returns true if any line of the IO object + # has a line that +include?+ the given part. + # + # @param [IO] io An IO object from Open3. + # @param [String] part The string parameter to +contains?+ + # @return [Boolean or nil] True iff any line of io includes the part + def io_include?(io, part) + io.each_line do |l| + return true if l.include?(part) + end + end + + # Executes the given arguments for +client_command+ on the shell + # and returns the resulting stdout. + # + # May optionally specify an opts hash with flags for popen3 and Process.spawn + # (cf., :binmode, :stdin_data in +Open3.capture3+) + # + # If the operation throws an exception or the operation yields a non-zero exit code + # we rethrow a +CommandFailed+ with a meaningful error message + def capture_out(args, opts = {}) + output, err, code = Open3.capture3(client_command, *args, binmode: opts[:binmode]) + if code != 0 + error_msg = "SCM command failed: Non-zero exit code (#{code}) for `#{client_command}`" + logger.error(error_msg) + logger.debug("Error output is #{err}") + raise Exceptions::CommandFailed.new(client_command, error_msg, err) + end + + output + end + + # Executes the given arguments for +client_command+ on the shell + # and returns stdout, stderr, and the exit code. + # + # If the operation throws an exception or the operation we rethrow a + # +CommandFailed+ with a meaningful error message. + def popen3(args, opts = {}, &block) + logger.debug "Shelling out: `#{stripped_command(args)}`" + Open3.popen3(client_command, *args, opts, &block) + rescue Exceptions::ScmError => e + raise e + rescue => e + error_msg = "SCM command for `#{client_command}` failed: #{strip_credential(e.message)}" + logger.error(error_msg) + raise Exceptions::CommandFailed.new(client_command, error_msg) + end + + ## + # Returns the full client command and args with stripped credentials + def stripped_command(args) + "#{client_command} #{strip_credential(args.join(' '))}" + end + + ## + # Replaces argument values for --username/--password in a given command + # with a placeholder + def strip_credential(cmd) + q = Redmine::Platform.mswin? ? '"' : "'" + cmd.to_s.gsub(/(\-\-(password|username))\s+(#{q}[^#{q}]+#{q}|[^#{q}]\S+)/, '\\1 xxxx') + end + + def scm_encode(to, from, str) + return nil if str.nil? + return str if to == from + begin + str.to_s.encode(to, from) + rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError => err + logger.error("failed to convert from #{from} to #{to}. #{err}") + nil + end + end + + private + + ## + # Counts the repositories by files in ruby. + # For sake of compatibility, iterates all files + # in the repository to determine storage size. + # + # This is compatible, but quite inefficient, so should + # be run asynchronously. + def count_required_storage + bytes = 0 + Find.find(local_repository_path) do |f| + bytes += File.size(f) if File.file?(f) + end + + bytes + end + end + end + end +end diff --git a/lib/open_project/scm/adapters/subversion.rb b/lib/open_project/scm/adapters/subversion.rb new file mode 100644 index 00000000000..8255e0e9e72 --- /dev/null +++ b/lib/open_project/scm/adapters/subversion.rb @@ -0,0 +1,333 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'uri' + +module OpenProject + module Scm + module Adapters + class Subversion < Base + include LocalClient + + def client_command + @client_command ||= self.class.config[:client_command] || 'svn' + end + + def svnadmin_command + @svnadmin_command ||= (self.class.config[:svnadmin_command] || 'svnadmin') + end + + def client_version + @client_version ||= (svn_binary_version || []) + end + + def svn_binary_version + scm_version = scm_version_from_command_line.dup + m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)}) + if m + m[2].scan(%r{\d+}).map(&:to_i) + end + end + + def scm_version_from_command_line + capture_out('--version') + end + + ## + # Subversion may be local or remote, + # for now determine it by the URL type. + def local? + url.start_with?('file://') + end + + ## + # Returns the local repository path + # (if applicable). + def local_repository_path + root_url.sub('file://', '') + end + + def initialize(url, root_url = nil, login = nil, password = nil, _path_encoding = nil) + super(url, root_url) + + @login = login + @password = password + end + + ## + # Checks the status of this repository and throws unless it can be accessed + # correctly by the adapter. + # + # @raise [ScmUnavailable] raised when repository is unavailable. + def check_availability! + # Check whether we can access svn repository uuid + popen3(['info', '--xml', target]) do |stdout, stderr| + doc = Nokogiri::XML(stdout.read) + + raise Exceptions::ScmEmpty if doc.at_xpath('/info/entry/commit[@revision="0"]') + + return if doc.at_xpath('/info/entry/repository/uuid') + + raise Exceptions::ScmUnauthorized.new if io_include?(stderr, + 'E215004: Authentication failed') + end + + raise Exceptions::ScmUnavailable + end + + ## + # Creates an empty repository using svnadmin + # + def create_empty_svn + _, err, code = Open3.capture3(svnadmin_command, 'create', root_url) + if code != 0 + msg = "Failed to create empty subversion repository with `#{svnadmin_command} create`" + logger.error(msg) + logger.debug("Error output is #{err}") + raise Exceptions::CommandFailed.new(client_command, msg) + end + end + + # Get info about the svn repository + def info + cmd = build_svn_cmd(['info', '--xml', target]) + xml_capture(cmd, force_encoding: true) do |doc| + Info.new( + root_url: doc.xpath('/info/entry/repository/root').text, + lastrev: extract_revision(doc.at_xpath('/info/entry/commit')) + ) + end + end + + def entries(path = nil, identifier = nil) + path ||= '' + identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD' + entries = Entries.new + cmd = ['list', '--xml', "#{target(path)}@#{identifier}"] + xml_capture(cmd, force_encoding: true) do |doc| + doc.xpath('/lists/list/entry').each { |list| entries << extract_entry(list, path) } + end + entries.sort_by_name + end + + def properties(path, identifier = nil) + # proplist xml output supported in svn 1.5.0 and higher + return nil unless client_version_above?([1, 5, 0]) + + identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD' + cmd = ['proplist', '--verbose', '--xml', "#{target(path)}@#{identifier}"] + properties = {} + xml_capture(cmd, force_encoding: true) do |doc| + doc.xpath('/properties/target/property').each do |prop| + properties[prop['name']] = prop.text + end + end + + properties + end + + def revisions(path = nil, identifier_from = nil, identifier_to = nil, options = {}) + revisions = Revisions.new + fetch_revision_entries(identifier_from, identifier_to, options, path) do |logentry| + paths = logentry.xpath('paths/path').map { |entry| build_path(entry) } + paths.sort! { |x, y| x[:path] <=> y[:path] } + + r = extract_revision(logentry) + r.paths = paths + + revisions << r + end + revisions + end + + def diff(path, identifier_from, identifier_to = nil, _type = 'inline') + path ||= '' + + identifier_from = numeric_identifier(identifier_from) + identifier_to = numeric_identifier(identifier_to, identifier_from - 1) + + cmd = ['diff', '-r', "#{identifier_to}:#{identifier_from}", + "#{target(path)}@#{identifier_from}"] + capture_svn(cmd).lines.map(&:chomp) + end + + def numeric_identifier(identifier, default = '') + if identifier && identifier.to_i > 0 + identifier.to_i + else + default + end + end + + def cat(path, identifier = nil) + identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD' + cmd = ['cat', "#{target(path)}@#{identifier}"] + capture_svn(cmd) + end + + def annotate(path, identifier = nil) + identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD' + cmd = ['blame', "#{target(path)}@#{identifier}"] + blame = Annotate.new + popen3(cmd) do |io, _| + io.each_line do |line| + next unless line =~ %r{^\s*(\d+)\s*(\S+)\s(.*)$} + blame.add_line($3.rstrip, Revision.new(identifier: $1.to_i, author: $2.strip)) + end + end + blame + end + + private + + ## + # Builds the SVM command arguments around the given parameters + # Appends to the parameter: + # --username, --password if specified for this repository + # --no-auth-cache force re-authentication + # --non-interactive avoid prompts + def build_svn_cmd(args) + if @login.present? + args.push('--username', shell_quote(@login)) + args.push('--password', shell_quote(@password)) if @password.present? + end + + args.push('--no-auth-cache', '--non-interactive') + end + + def xml_capture(cmd, opts = {}) + output = capture_svn(cmd, opts) + doc = Nokogiri::XML(output) + + # Yield helper methods instead of doc + yield doc + end + + def extract_entry(entry, path) + revision = extract_revision(entry.at_xpath('commit')) + kind, size, name = parse_entry(entry) + + # Skip directory if there is no commit date (usually that + # means that we don't have read access to it) + return if kind == 'dir' && revision.time.nil? + + Entry.new( + name: URI.unescape(name), + path: ((path.empty? ? '' : "#{path}/") + name), + kind: kind, + size: size.empty? ? nil : size.to_i, + lastrev: revision + ) + end + + def parse_entry(entry) + kind = entry['kind'] + size = entry.xpath('size').text + name = entry.xpath('name').text + + [kind, size, name] + end + + def build_path(entry) + { + action: entry['action'], + path: entry.text, + from_path: entry['copyfrom-path'], + from_revision: entry['copyfrom-rev'] + } + end + + def extract_revision(commit_node) + # We may be unauthorized to read the commit date + date = + begin + Time.parse(commit_node.xpath('date').text).localtime + rescue ArgumentError + nil + end + + Revision.new( + identifier: commit_node['revision'], + time: date, + message: commit_node.xpath('msg').text, + author: commit_node.xpath('author').text + ) + end + + def fetch_revision_entries(identifier_from, identifier_to, options, path, &block) + path ||= '' + identifier_from = numeric_identifier(identifier_from, 'HEAD') + identifier_to = numeric_identifier(identifier_to, 1) + cmd = ['log', '--xml', '-r', "#{identifier_from}:#{identifier_to}"] + cmd << '--verbose' if options[:with_paths] + cmd << '--limit' << options[:limit].to_s if options[:limit] + cmd << target(path) + xml_capture(cmd, force_encoding: true) do |doc| + doc.xpath('/log/logentry').each &block + end + end + + def target(path = '') + base = path.match(/\A\//) ? root_url : url + uri = "#{base}/#{path}" + URI.escape(URI.escape(uri), '[]') + # shell_quote(uri.gsub(/[?<>\*]/, '')) + end + + ## + # Builds the full git arguments from the parameters + # and return the executed stdout as a string + def capture_svn(args, opt = {}) + cmd = build_svn_cmd(args) + output = capture_out(cmd) + + if opt[:force_encoding] && output.respond_to?(:force_encoding) + output.force_encoding('UTF-8') + end + + output + end + + ## + # Builds the full git arguments from the parameters + # and calls the given block with in, out, err, thread + # from +Open3#popen3+. + def popen3(args, &block) + cmd = build_svn_cmd(args) + super(cmd) do |_stdin, stdout, stderr, wait_thr| + block.call(stdout, stderr) + + process = wait_thr.value + return process.exitstatus == 0 + end + end + end + end + end +end diff --git a/lib/open_project/scm/exceptions.rb b/lib/open_project/scm/exceptions.rb new file mode 100644 index 00000000000..afcf6f49784 --- /dev/null +++ b/lib/open_project/scm/exceptions.rb @@ -0,0 +1,94 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ +module OpenProject + module Scm + module Exceptions + # Parent SCM exception class + class ScmError < StandardError + end + + # Exception marking an error in the repository build process + class RepositoryBuildError < ScmError + end + + # Exception marking an error in the repository teardown process + class RepositoryUnlinkError < ScmError + end + + # Exception marking an error in the execution of a local command. + class CommandFailed < ScmError + attr_reader :program + attr_reader :message + attr_reader :stderr + + # Create a +CommandFailed+ exception for the executed program (e.g., 'svn'), + # and a meaningful error message + # + # If the operation throws an exception or the operation we rethrow a + # +ShellError+ with a meaningful error message. + def initialize(program, message, stderr = nil) + @program = program + @message = message + @stderr = stderr + end + + def to_s + s = "CommandFailed(#{@program}) -> #{@message}" + s << "(#{@stderr})" unless @stderr.nil? + + s + end + end + + # a localized exception raised when SCM could be accessed + class ScmUnavailable < ScmError + def initialize(key = 'unavailable') + @error = I18n.t("repositories.errors.#{key}") + end + + def to_s + @error + end + end + + # raised if SCM could not be accessed due to authorization failure + class ScmUnauthorized < ScmUnavailable + def initialize + super('unauthorized') + end + end + # raised when encountering an empty (bare) repository + class ScmEmpty < ScmUnavailable + def initialize + super('empty_repository') + end + end + end + end +end diff --git a/lib/open_project/scm/manageable_repository.rb b/lib/open_project/scm/manageable_repository.rb new file mode 100644 index 00000000000..9e0725efc52 --- /dev/null +++ b/lib/open_project/scm/manageable_repository.rb @@ -0,0 +1,114 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +module OpenProject + module Scm + module ManageableRepository + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + ## + # We let SCM vendor implementation define their own + # types (e.g., for differences in the management of + # local vs. remote repositories). + # + # But if they are manageable by OpenProject, they must + # expose this type through +available_types+. + def managed_type + :managed + end + + ## + # Reads from configuration whether new repositories of this kind + # may be managed from OpenProject. + def manageable? + !(disabled_types.include?(managed_type) || managed_root.nil?) + end + + ## + # Returns the managed root for this repository vendor + def managed_root + scm_config[:manages] + end + end + + ## + # + def manageable? + self.class.manageable? + end + + ## + # Determines whether this repository IS currently managed + # by openproject + def managed? + scm_type.to_sym == self.class.managed_type + end + + ## + # Allows descendants to perform actions + # with the given repository after the managed + # repository has been written to file system. + def managed_repo_created(_args) + nil + end + + ## + # Returns the absolute path to the repository + # under the +managed_root+ path defined in the configuration + # of this adapter. + # Used only in the creation of a repository, at a later point + # in time, it is referred to in the root_url + def managed_repository_path + File.join(self.class.managed_root, repository_identifier) + end + + ## + # Returns the access url to the repository + # May be overridden by descendants + # Used only in the creation of a repository, at a later point + # in time, it is referred to in the url + def managed_repository_url + "file://#{managed_repository_path}" + end + + protected + + ## + # Repository relative path from scm managed root. + # Will be overridden by including models to, e.g., + # append '.git' to that path. + def repository_identifier + project.identifier + end + end + end +end diff --git a/lib/redmine/scm/base.rb b/lib/open_project/scm/manager.rb similarity index 73% rename from lib/redmine/scm/base.rb rename to lib/open_project/scm/manager.rb index 7181b008cc1..d168b3f331c 100644 --- a/lib/redmine/scm/base.rb +++ b/lib/open_project/scm/manager.rb @@ -27,31 +27,36 @@ # See doc/COPYRIGHT.rdoc for more details. #++ -module Redmine +module OpenProject module Scm - class Base + class Manager class << self - def all - @scms + def registered + @scms ||= {} end - def configured - @scms.select do |scm_name| - klass = Repository.const_get(scm_name) + def vendors + @scms.keys + end - klass.configured? - end + ## + # Returns all enabled repositories as a Hash + # { vendor_name: repository class constant } + def enabled + registered.select { |scm| Setting.enabled_scm.include?(scm) } end # Add a new SCM adapter and repository def add(scm_name) - @scms ||= [] - @scms << scm_name + # Force model lookup to avoid + # const errors later on. + klass = Repository.const_get(scm_name) + registered[scm_name] = klass end # Remove a SCM adapter from Redmine's list of supported scms def delete(scm_name) - @scms.delete(scm_name) + registered.delete(scm_name) end end end diff --git a/lib/open_project/version.rb b/lib/open_project/version.rb index b5c04d91b74..5771430fe57 100644 --- a/lib/open_project/version.rb +++ b/lib/open_project/version.rb @@ -31,8 +31,8 @@ require 'rexml/document' module OpenProject module VERSION #:nodoc: - MAJOR = 4 - MINOR = 3 + MAJOR = 5 + MINOR = 0 PATCH = 0 TINY = PATCH # Redmine compat @@ -48,7 +48,7 @@ module OpenProject # # 2.0.0debian-2 def self.special - '' + '-alpha' end def self.revision diff --git a/lib/plugins/acts_as_event/lib/acts_as_event.rb b/lib/plugins/acts_as_event/lib/acts_as_event.rb index cafe3c49cab..b0456733e9d 100644 --- a/lib/plugins/acts_as_event/lib/acts_as_event.rb +++ b/lib/plugins/acts_as_event/lib/acts_as_event.rb @@ -92,12 +92,11 @@ module Redmine end end - # Returns the mail adresses of users that should be notified + # Returns users that should be notified def recipients notified = [] notified = project.notified_users if project - notified.reject! { |user| !visible?(user) } - notified.map(&:mail) + notified.select { |user| visible?(user) } end module ClassMethods diff --git a/lib/plugins/acts_as_journalized/lib/journal_changes.rb b/lib/plugins/acts_as_journalized/lib/journal_changes.rb new file mode 100644 index 00000000000..4dfa90fe241 --- /dev/null +++ b/lib/plugins/acts_as_journalized/lib/journal_changes.rb @@ -0,0 +1,86 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +#-- encoding: UTF-8 +module JournalChanges + def get_changes + return @changes if @changes + return {} if data.nil? + + @changes = HashWithIndifferentAccess.new + + if predecessor.nil? + @changes = data.journaled_attributes + .reject { |_, new_value| new_value.nil? } + .inject({}) { |result, (attribute, new_value)| + result[attribute] = [nil, new_value] + result + } + else + normalized_new_data = JournalManager.normalize_newlines(data.journaled_attributes) + normalized_old_data = JournalManager.normalize_newlines(predecessor.data.journaled_attributes) + + normalized_new_data.select { |attribute, new_value| + # we dont record changes for changes from nil to empty strings and vice versa + old_value = normalized_old_data[attribute] + new_value != old_value && (new_value.present? || old_value.present?) + }.each do |attribute, new_value| + @changes[attribute] = [normalized_old_data[attribute], new_value] + end + end + + @changes.merge!(get_association_changes predecessor, 'attachable', 'attachments', :attachment_id, :filename) + @changes.merge!(get_association_changes predecessor, 'customizable', 'custom_fields', :custom_field_id, :value) + end + + def get_association_changes(predecessor, journal_association, association, key, value) + changes = {} + journal_assoc_name = "#{journal_association}_journals" + + if predecessor.nil? + send(journal_assoc_name).each_with_object(changes) { |associated_journal, h| + changed_attribute = "#{association}_#{associated_journal.send(key)}" + new_value = associated_journal.send(value) + h[changed_attribute] = [nil, new_value] + } + else + new_journals = send(journal_assoc_name).map(&:attributes) + old_journals = predecessor.send(journal_assoc_name).map(&:attributes) + + merged_journals = JournalManager.merge_reference_journals_by_id new_journals, + old_journals, + key.to_s + + changes.merge! JournalManager.added_references(merged_journals, association, value.to_s) + changes.merge! JournalManager.removed_references(merged_journals, association, value.to_s) + changes.merge! JournalManager.changed_references(merged_journals, association, value.to_s) + end + + changes + end +end diff --git a/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/changes.rb b/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/changes.rb index f480c521b2f..1792b61575a 100644 --- a/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/changes.rb +++ b/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/changes.rb @@ -96,7 +96,7 @@ module Redmine::Acts::Journalized backward ? chain.pop : chain.shift unless from_number == 1 || to_number == 1 chain.inject({}) do |changes, journal| - changes.append_changes!(backward ? journal.changed_data.reverse_changes : journal.changed_data) + changes.append_changes!(backward ? journal.details.reverse_changes : journal.details) end end diff --git a/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/creation.rb b/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/creation.rb index c4a473f42fb..f5c41f49187 100644 --- a/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/creation.rb +++ b/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/creation.rb @@ -112,10 +112,10 @@ module Redmine::Acts::Journalized # Try to find the real initial values unless journals.empty? journals[1..-1].each do |journal| - unless journal.changed_data[name].nil? + unless journal.details[name].nil? # Found the first change in journals # Copy the first value as initial change value - initial_changes[name] = journal.changed_data[name].first + initial_changes[name] = journal.details[name].first break end end @@ -179,9 +179,9 @@ module Redmine::Acts::Journalized # Specifies the attributes used during journal creation. This is separated into its own # method so that it can be overridden by the VestalVersions::Users feature. def journal_attributes - attributes = { journaled_id: id, activity_type: activity_type, - changed_data: journal_changes, version: last_version + 1, - notes: journal_notes, user_id: (journal_user.try(:id) || User.current.try(:id)) + { journaled_id: id, activity_type: activity_type, + details: journal_changes, version: last_version + 1, + notes: journal_notes, user_id: (journal_user.try(:id) || User.current.try(:id)) }.merge(extra_journal_attributes || {}) end end diff --git a/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/deprecated.rb b/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/deprecated.rb index a0b8fb48435..f23b21dbb50 100644 --- a/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/deprecated.rb +++ b/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/deprecated.rb @@ -55,8 +55,7 @@ module Redmine::Acts::Journalized def recipients notified = [] notified = project.notified_users if project - notified.reject! { |user| !visible?(user) } - notified.map(&:mail) + notified.select { |user| visible?(user) } end def current_journal diff --git a/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/save_hooks.rb b/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/save_hooks.rb index 44d8a74b355..b2849535a80 100644 --- a/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/save_hooks.rb +++ b/lib/plugins/acts_as_journalized/lib/redmine/acts/journalized/save_hooks.rb @@ -66,12 +66,21 @@ module Redmine::Acts::Journalized add_journal = journals.empty? || JournalManager.changed?(self) || !@journal_notes.empty? - JournalManager.add_journal self, @journal_user, @journal_notes if add_journal + journal = JournalManager.add_journal self, @journal_user, @journal_notes if add_journal journals.select(&:new_record?).each do |journal| save_journal_with_retry(journal) end + if add_journal + OpenProject::Notifications.send('journal_created', + journal: journal, + send_notification: JournalManager.send_notification) + end + + # Need to clear the notification setting after each usage otherwise it might be cached + JournalManager.reset_notification + @journal_user = nil @journal_notes = nil end diff --git a/lib/plugins/acts_as_journalized/test/changes_test.rb b/lib/plugins/acts_as_journalized/test/changes_test.rb index 23e0867a471..cabaaa946a3 100644 --- a/lib/plugins/acts_as_journalized/test/changes_test.rb +++ b/lib/plugins/acts_as_journalized/test/changes_test.rb @@ -34,7 +34,7 @@ class ChangesTest < Test::Unit::TestCase setup do @user = User.create(name: 'Steve Richert') @user.update_attribute(:last_name, 'Jobs') - @changes = @user.journals.last.changed_data + @changes = @user.journals.last.details end should 'be a hash' do @@ -73,7 +73,7 @@ class ChangesTest < Test::Unit::TestCase @user.first_name = 'Stephen' model_changes = @user.changed_data @user.save - changes = @user.journals.last.changed_data + changes = @user.journals.last.details assert_equal model_changes, changes end end diff --git a/lib/plugins/acts_as_journalized/test/creation_test.rb b/lib/plugins/acts_as_journalized/test/creation_test.rb index 2bbd140af06..e957983c96c 100644 --- a/lib/plugins/acts_as_journalized/test/creation_test.rb +++ b/lib/plugins/acts_as_journalized/test/creation_test.rb @@ -81,7 +81,7 @@ class CreationTest < Test::Unit::TestCase should 'not contain Rails timestamps' do %w(created_at created_on updated_at updated_on).each do |timestamp| - assert_does_not_contain @user.journals.last.changed_data.keys, timestamp + assert_does_not_contain @user.journals.last.details.keys, timestamp end end @@ -93,7 +93,7 @@ class CreationTest < Test::Unit::TestCase end should 'only contain the specified columns' do - assert_equal @only, @user.journals.last.changed_data.keys + assert_equal @only, @user.journals.last.details.keys end teardown do @@ -110,7 +110,7 @@ class CreationTest < Test::Unit::TestCase should 'not contain the specified columns' do @except.each do |column| - assert_does_not_contain @user.journals.last.changed_data.keys, column + assert_does_not_contain @user.journals.last.details.keys, column end end @@ -128,7 +128,7 @@ class CreationTest < Test::Unit::TestCase end should 'respect only the :only options' do - assert_equal @only, @user.journals.last.changed_data.keys + assert_equal @only, @user.journals.last.details.keys end teardown do diff --git a/lib/plugins/acts_as_watchable/lib/acts_as_watchable.rb b/lib/plugins/acts_as_watchable/lib/acts_as_watchable.rb index 7235a3dbf65..ff211bd81ee 100644 --- a/lib/plugins/acts_as_watchable/lib/acts_as_watchable.rb +++ b/lib/plugins/acts_as_watchable/lib/acts_as_watchable.rb @@ -146,12 +146,10 @@ module Redmine watcher_user_ids.any? { |uid| uid == user.id })) end - # Returns an array of watchers' email addresses + # Returns an array of watchers def watcher_recipients notified = watcher_users.active.where(['mail_notification != ?', 'none']) - notified.select! do |user| possible_watcher?(user) end - - notified.map(&:mail).compact + notified.select { |user| possible_watcher?(user) } end module ClassMethods; end diff --git a/lib/redmine/default_data/loader.rb b/lib/redmine/default_data/loader.rb deleted file mode 100644 index 3d23b5014b0..00000000000 --- a/lib/redmine/default_data/loader.rb +++ /dev/null @@ -1,313 +0,0 @@ -#-- encoding: UTF-8 -#-- copyright -# OpenProject is a project management system. -# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See doc/COPYRIGHT.rdoc for more details. -#++ - -module Redmine - module DefaultData - class DataAlreadyLoaded < Exception; end - - module Loader - include Redmine::I18n - - class << self - # Returns true if no data is already loaded in the database - # otherwise false - def no_data? - !Role.where(builtin: 0).first && - !::Type.where(is_standard: false).first && - !Status.first && - !Enumeration.first - end - - # Loads the default data - # Raises a RecordNotSaved exception if something goes wrong - def load(lang = nil) - raise DataAlreadyLoaded.new('Some configuration data is already loaded.') unless no_data? - set_language_if_valid(lang) - - Role.transaction do - # Roles - manager = Role.create! name: l(:default_role_manager), - position: 3 - manager.permissions = manager.setable_permissions.map(&:name) - manager.save! - - member = Role.create! name: l(:default_role_member), - position: 4, - permissions: [:view_work_packages, - :export_work_packages, - :add_work_packages, - :move_work_packages, - :edit_work_packages, - :add_work_package_notes, - :edit_own_work_package_notes, - :manage_work_package_relations, - :manage_subtasks, - :manage_public_queries, - :save_queries, - :view_work_package_watchers, - :add_work_package_watchers, - :delete_work_package_watchers, - :view_calendar, - :comment_news, - :log_time, - :view_time_entries, - :edit_own_time_entries, - :view_project_associations, - :view_timelines, - :edit_timelines, - :delete_timelines, - :view_reportings, - :edit_reportings, - :delete_reportings, - :manage_wiki, - :manage_wiki_menu, - :rename_wiki_pages, - :change_wiki_parent_page, - :delete_wiki_pages, - :view_wiki_pages, - :export_wiki_pages, - :view_wiki_edits, - :edit_wiki_pages, - :delete_wiki_pages_attachments, - :protect_wiki_pages, - :list_attachments, - :add_messages, - :edit_own_messages, - :delete_own_messages, - :browse_repository, - :view_changesets, - :commit_access, - :view_commit_author_statistics] - - reader = Role.create! name: l(:default_role_reader), - position: 5, - permissions: [:view_work_packages, - :add_work_package_notes, - :edit_own_work_package_notes, - :save_queries, - :view_calendar, - :comment_news, - :view_project_associations, - :view_timelines, - :view_reportings, - :view_wiki_pages, - :export_wiki_pages, - :view_wiki_edits, - :edit_wiki_pages, - :list_attachments, - :add_messages, - :edit_own_messages, - :delete_own_messages, - :browse_repository, - :view_changesets] - - Role.non_member.update_attributes name: l(:default_role_non_member), - permissions: [:view_work_packages, - :view_calendar, - :comment_news, - :browse_repository, - :view_changesets, - :view_wiki_pages] - - Role.anonymous.update_attributes name: l(:default_role_anonymous), - permissions: [:view_work_packages, - :browse_repository, - :view_changesets, - :view_wiki_pages] - - # Colors - colors_list = PlanningElementTypeColor.colors - colors = Hash[*(colors_list.map { |color| - color.save - color.reload - [color.name.to_sym, color.id] - }).flatten] - - # Types - task = ::Type.create! name: l(:default_type_task), - color_id: colors[:Mint], - is_default: false, - is_in_roadmap: true, - in_aggregation: true, - is_milestone: false, - position: 1 - - deliverable = ::Type.create! name: l(:default_type_deliverable), - is_default: false, - color_id: colors[:Orange], - is_in_roadmap: true, - in_aggregation: true, - is_milestone: false, - position: 2 - - milestone = ::Type.create! name: l(:default_type_milestone), - is_default: true, - color_id: colors[:Purple], - is_in_roadmap: false, - in_aggregation: true, - is_milestone: true, - position: 3 - - phase = ::Type.create! name: l(:default_type_phase), - is_default: true, - color_id: colors[:Lime], - is_in_roadmap: false, - in_aggregation: true, - is_milestone: false, - position: 4 - - bug = ::Type.create! name: l(:default_type_bug), - is_default: false, - color_id: colors[:'Red-bright'], - is_in_roadmap: true, - in_aggregation: true, - is_milestone: false, - position: 5 - - feature = ::Type.create! name: l(:default_type_feature), - is_default: false, - color_id: colors[:Blue], - is_in_roadmap: true, - in_aggregation: true, - is_milestone: false, - position: 6 - - none = ::Type.standard_type - - # Issue statuses - new = Status.create!(name: l(:default_status_new), - is_closed: false, - is_default: true, - position: 1) - specified = Status.create!(name: l(:default_status_specified), - is_closed: false, - is_default: false, - position: 2) - confirmed = Status.create!(name: l(:default_status_confirmed), - is_closed: false, - is_default: false, - position: 3) - to_be_scheduled = Status.create!(name: l(:default_status_to_be_scheduled), - is_closed: false, - is_default: false, - position: 4) - scheduled = Status.create!(name: l(:default_status_scheduled), - is_closed: false, - is_default: false, - position: 5) - in_progress = Status.create!(name: l(:default_status_in_progress), - is_closed: false, - is_default: false, - position: 6) - tested = Status.create!(name: l(:default_status_tested), - is_closed: false, - is_default: false, - position: 7) - on_hold = Status.create!(name: l(:default_status_on_hold), - is_closed: false, - is_default: false, - position: 8) - rejected = Status.create!(name: l(:default_status_rejected), - is_closed: true, - is_default: false, - position: 9) - closed = Status.create!(name: l(:default_status_closed), - is_closed: true, - is_default: false, - position: 10) - - # Workflow - Each type has its own workflow - workflows = { task.id => [new, in_progress, on_hold, rejected, closed], - deliverable.id => [new, specified, in_progress, on_hold, rejected, closed], - none.id => [new, in_progress, rejected, closed], - milestone.id => [new, to_be_scheduled, scheduled, in_progress, on_hold, rejected, closed], - phase.id => [new, to_be_scheduled, scheduled, in_progress, on_hold, rejected, closed], - bug.id => [new, confirmed, in_progress, tested, on_hold, rejected, closed], - feature.id => [new, specified, confirmed, in_progress, tested, on_hold, rejected, closed] } - workflows.each do |type_id, statuses_for_type| - statuses_for_type.each { |old_status| - statuses_for_type.each { |new_status| - [manager.id, member.id].each { |role_id| - Workflow.create!(type_id: type_id, - role_id: role_id, - old_status_id: old_status.id, - new_status_id: new_status.id) - } - } - } - end - - # Enumerations - - IssuePriority.create!(name: l(:default_priority_low), position: 1) - IssuePriority.create!(name: l(:default_priority_normal), position: 2, is_default: true) - IssuePriority.create!(name: l(:default_priority_high), position: 3) - IssuePriority.create!(name: l(:default_priority_immediate), position: 5) - - TimeEntryActivity.create!(name: l(:default_activity_management), position: 1, is_default: true) - TimeEntryActivity.create!(name: l(:default_activity_design), position: 2) - TimeEntryActivity.create!(name: l(:default_activity_development), position: 3) - TimeEntryActivity.create!(name: l(:default_activity_testing), position: 4) - TimeEntryActivity.create!(name: l(:default_activity_Support), position: 5) - TimeEntryActivity.create!(name: l(:default_activity_Other), position: 6) - - ReportedProjectStatus.create!(name: l(:default_reported_project_status_green), is_default: true) - ReportedProjectStatus.create!(name: l(:default_reported_project_status_amber), is_default: false) - ReportedProjectStatus.create!(name: l(:default_reported_project_status_red), is_default: false) - - # Project types - - ProjectType.create!(name: l(:default_project_type_customer)) - ProjectType.create!(name: l(:default_project_type_internal)) - - reported_status_ids = ReportedProjectStatus.all.map(&:id) - ProjectType.all.each do |project| - project.update_attributes(reported_project_status_ids: reported_status_ids) - end - - Setting['notified_events'] = ['work_package_added', \ - 'work_package_updated',\ - 'work_package_note_added',\ - 'work_package_updated',\ - 'status_updated',\ - 'work_package_updated',\ - 'work_package_priority_updated',\ - 'work_package_updated',\ - 'news_added', 'news_comment_added',\ - 'file_added',\ - 'message_posted',\ - 'wiki_content_added',\ - 'wiki_content_updated'] - end - true - end - end - end - end -end diff --git a/lib/redmine/i18n.rb b/lib/redmine/i18n.rb index ec4b5832bca..823bd5854dc 100644 --- a/lib/redmine/i18n.rb +++ b/lib/redmine/i18n.rb @@ -109,8 +109,10 @@ module Redmine end end + ## + # Returns the given language if it is valid or nil otherwise. def find_language(lang) - valid_languages.detect { |l| l =~ /#{lang}/i } + valid_languages.detect { |l| l =~ /#{lang}/i } if lang.present? end def set_language_if_valid(lang) diff --git a/lib/redmine/menu_manager/menu_helper.rb b/lib/redmine/menu_manager/menu_helper.rb index 527cb328981..d11c8d77beb 100644 --- a/lib/redmine/menu_manager/menu_helper.rb +++ b/lib/redmine/menu_manager/menu_helper.rb @@ -201,7 +201,6 @@ module Redmine::MenuManager::MenuHelper html_options[:title] ||= caption html_options[:lang] = menu_item_locale(item) - link_to link_text, url, html_options end diff --git a/lib/redmine/scm/adapters/abstract_adapter.rb b/lib/redmine/scm/adapters/abstract_adapter.rb deleted file mode 100644 index cf9aef9ef41..00000000000 --- a/lib/redmine/scm/adapters/abstract_adapter.rb +++ /dev/null @@ -1,365 +0,0 @@ -#-- encoding: UTF-8 -#-- copyright -# OpenProject is a project management system. -# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See doc/COPYRIGHT.rdoc for more details. -#++ - -require 'cgi' - -module Redmine - module Scm - module Adapters - class CommandFailed < StandardError #:nodoc: - end - - class AbstractAdapter #:nodoc: - class << self - def client_command - '' - end - - # Returns the version of the scm client - # Eg: [1, 5, 0] or [] if unknown - def client_version - [] - end - - # Returns the version string of the scm client - # Eg: '1.5.0' or 'Unknown version' if unknown - def client_version_string - v = client_version || 'Unknown version' - v.is_a?(Array) ? v.join('.') : v.to_s - end - - # Returns true if the current client version is above - # or equals the given one - # If option is :unknown is set to true, it will return - # true if the client version is unknown - def client_version_above?(v, options = {}) - ((client_version <=> v) >= 0) || (client_version.empty? && options[:unknown]) - end - - def client_available - true - end - - def shell_quote(str) - if Redmine::Platform.mswin? - '"' + str.gsub(/"/, '\\"') + '"' - else - "'" + str.gsub(/'/, "'\"'\"'") + "'" - end - end - end - - def initialize(url, root_url = nil, login = nil, password = nil, - _path_encoding = nil) - @url = url - @login = login if login && !login.empty? - @password = (password || '') if @login - @root_url = root_url.blank? ? retrieve_root_url : root_url - end - - def adapter_name - 'Abstract' - end - - def supports_cat? - true - end - - def supports_annotate? - respond_to?('annotate') - end - - def root_url - @root_url - end - - def url - @url - end - - # get info about the svn repository - def info - nil - end - - # Returns the entry identified by path and revision identifier - # or nil if entry doesn't exist in the repository - def entry(path = nil, identifier = nil) - parts = path.to_s.split(%r{[\/\\]}).select { |n| !n.blank? } - search_path = parts[0..-2].join('/') - search_name = parts[-1] - if search_path.blank? && search_name.blank? - # Root entry - Entry.new(path: '', kind: 'dir') - else - # Search for the entry in the parent directory - es = entries(search_path, identifier) - es ? es.detect { |e| e.name == search_name } : nil - end - end - - # Returns an Entries collection - # or nil if the given path doesn't exist in the repository - def entries(_path = nil, _identifier = nil) - nil - end - - def branches - nil - end - - def tags - nil - end - - def default_branch - nil - end - - def properties(_path, _identifier = nil) - nil - end - - def revisions(_path = nil, _identifier_from = nil, _identifier_to = nil, _options = {}) - nil - end - - def diff(_path, _identifier_from, _identifier_to = nil) - nil - end - - def cat(_path, _identifier = nil) - nil - end - - def with_leading_slash(path) - path ||= '' - (path[0, 1] != '/') ? "/#{path}" : path - end - - def with_trailling_slash(path) - path ||= '' - (path[-1, 1] == '/') ? path : "#{path}/" - end - - def without_leading_slash(path) - path ||= '' - path.gsub(%r{\A/+}, '') - end - - def without_trailling_slash(path) - path ||= '' - (path[-1, 1] == '/') ? path[0..-2] : path - end - - def shell_quote(str) - self.class.shell_quote(str) - end - - private - - def retrieve_root_url - info = self.info - info ? info.root_url : nil - end - - def target(path) - path ||= '' - base = path.match(/\A\//) ? root_url : url - shell_quote("#{base}/#{path}".gsub(/[?<>\*]/, '')) - end - - def logger - self.class.logger - end - - def shellout(cmd, &block) - self.class.shellout(cmd, &block) - end - - def self.logger - Rails.logger - end - - def self.shellout(cmd, &block) - logger.debug "Shelling out: #{strip_credential(cmd)}" if logger && logger.debug? - if Rails.env == 'development' - # Capture stderr when running in dev environment - cmd = "#{cmd} 2>>#{Rails.root}/log/scm.stderr.log" - end - begin - if RUBY_VERSION < '1.9' - mode = 'r+' - else - mode = 'r+:ASCII-8BIT' - end - IO.popen(cmd, mode) do |io| - io.close_write - block.call(io) if block_given? - end - rescue Errno::ENOENT => e - msg = strip_credential(e.message) - # The command failed, log it and re-raise - logger.error("SCM command failed, make sure that your SCM binary (eg. svn) is in PATH (#{ENV['PATH']}): #{strip_credential(cmd)}\n with: #{msg}") - raise CommandFailed.new(msg) - end - end - - # Hides username/password in a given command - def self.strip_credential(cmd) - q = (Redmine::Platform.mswin? ? '"' : "'") - cmd.to_s.gsub(/(\-\-(password|username))\s+(#{q}[^#{q}]+#{q}|[^#{q}]\S+)/, '\\1 xxxx') - end - - def strip_credential(cmd) - self.class.strip_credential(cmd) - end - - def scm_encode(to, from, str) - return nil if str.nil? - return str if to == from - begin - str.to_s.encode(to, from) - rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError => err - logger.error("failed to convert from #{from} to #{to}. #{err}") - nil - end - end - end - - class Entries < Array - def sort_by_name - sort {|x, y| - if x.kind == y.kind - x.name.to_s <=> y.name.to_s - else - x.kind <=> y.kind - end - } - end - - def revisions - revisions ||= Revisions.new(collect(&:lastrev).compact) - end - end - - class Info - attr_accessor :root_url, :lastrev - def initialize(attributes = {}) - self.root_url = attributes[:root_url] if attributes[:root_url] - self.lastrev = attributes[:lastrev] - end - end - - class Entry - attr_accessor :name, :path, :kind, :size, :lastrev - def initialize(attributes = {}) - self.name = attributes[:name] if attributes[:name] - self.path = attributes[:path] if attributes[:path] - self.kind = attributes[:kind] if attributes[:kind] - self.size = attributes[:size].to_i if attributes[:size] - self.lastrev = attributes[:lastrev] - end - - def is_file? - 'file' == kind - end - - def is_dir? - 'dir' == kind - end - - def is_text? - Redmine::MimeType.is_type?('text', name) - end - end - - class Revisions < Array - def latest - sort {|x, y| - unless x.time.nil? or y.time.nil? - x.time <=> y.time - else - 0 - end - }.last - end - end - - class Revision - attr_accessor :scmid, :name, :author, :time, :message, :paths, :revision, :branch - attr_writer :identifier - - def initialize(attributes = {}) - self.identifier = attributes[:identifier] - self.scmid = attributes[:scmid] - self.name = attributes[:name] || identifier - self.author = attributes[:author] - self.time = attributes[:time] - self.message = attributes[:message] || '' - self.paths = attributes[:paths] - self.revision = attributes[:revision] - self.branch = attributes[:branch] - end - - # Returns the identifier of this revision; see also Changeset model - def identifier - (@identifier || revision).to_s - end - - # Returns the readable identifier. - def format_identifier - identifier - end - end - - class Annotate - attr_reader :lines, :revisions - - def initialize - @lines = [] - @revisions = [] - end - - def add_line(line, revision) - @lines << line - @revisions << revision - end - - def content - content = lines.join("\n") - end - - def empty? - lines.empty? - end - end - end - end -end diff --git a/lib/redmine/scm/adapters/filesystem_adapter.rb b/lib/redmine/scm/adapters/filesystem_adapter.rb deleted file mode 100644 index ab6c108277a..00000000000 --- a/lib/redmine/scm/adapters/filesystem_adapter.rb +++ /dev/null @@ -1,122 +0,0 @@ -#-- encoding: UTF-8 -#-- copyright -# OpenProject is a project management system. -# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See doc/COPYRIGHT.rdoc for more details. -#++ - -require 'redmine/scm/adapters/abstract_adapter' -require 'find' - -module Redmine - module Scm - module Adapters - class FilesystemAdapter < AbstractAdapter - class << self - def client_available - true - end - end - - def initialize(url, _root_url = nil, _login = nil, _password = nil, - path_encoding = nil) - @url = with_trailling_slash(url) - @path_encoding = path_encoding || 'UTF-8' - end - - def format_path_ends(path, leading = true, trailling = true) - path = leading ? with_leading_slash(path) : - without_leading_slash(path) - trailling ? with_trailling_slash(path) : - without_trailling_slash(path) - end - - def info - info = Info.new(root_url: target, - lastrev: nil - ) - info - rescue CommandFailed - return nil - end - - def entries(path = '', _identifier = nil) - entries = Entries.new - trgt_utf8 = target(path) - trgt = scm_encode(@path_encoding, 'UTF-8', trgt_utf8) - Dir.new(trgt).each do |e1| - e_utf8 = scm_encode('UTF-8', @path_encoding, e1) - next if e_utf8.blank? - relative_path_utf8 = format_path_ends( - (format_path_ends(path, false, true) + e_utf8), false, false) - t1_utf8 = target(relative_path_utf8) - t1 = scm_encode(@path_encoding, 'UTF-8', t1_utf8) - relative_path = scm_encode(@path_encoding, 'UTF-8', relative_path_utf8) - e1 = scm_encode(@path_encoding, 'UTF-8', e_utf8) - if File.exist?(t1) and # paranoid test - %w{file directory}.include?(File.ftype(t1)) and # avoid special types - not File.basename(e1).match(/\A\.+\z/) # avoid . and .. - p1 = File.readable?(t1) ? relative_path : '' - utf_8_path = scm_encode('UTF-8', @path_encoding, p1) - entries << - Entry.new(name: scm_encode('UTF-8', @path_encoding, File.basename(e1)), - # below : list unreadable files, but dont link them. - path: utf_8_path, - kind: (File.directory?(t1) ? 'dir' : 'file'), - size: (File.directory?(t1) ? nil : [File.size(t1)].pack('l').unpack('L').first), - lastrev: - Revision.new(time: (File.mtime(t1))) - ) - end - end - entries.sort_by_name - rescue => err - logger.error "scm: filesystem: error: #{err.message}" - raise CommandFailed.new(err.message) - end - - def cat(path, _identifier = nil) - p = scm_encode(@path_encoding, 'UTF-8', target(path)) - File.new(p, 'rb').read - rescue => err - logger.error "scm: filesystem: error: #{err.message}" - raise CommandFailed.new(err.message) - end - - private - - # AbstractAdapter::target is implicitly made to quote paths. - # Here we do not shell-out, so we do not want quotes. - def target(path = nil) - # Prevent the use of .. - if path and !path.match(/(^|\/)\.\.(\/|$)/) - return "#{url}#{without_leading_slash(path)}" - end - url - end - end - end - end -end diff --git a/lib/redmine/scm/adapters/git_adapter.rb b/lib/redmine/scm/adapters/git_adapter.rb deleted file mode 100644 index 8f0d667d2e8..00000000000 --- a/lib/redmine/scm/adapters/git_adapter.rb +++ /dev/null @@ -1,369 +0,0 @@ -#-- encoding: UTF-8 -#-- copyright -# OpenProject is a project management system. -# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See doc/COPYRIGHT.rdoc for more details. -#++ - -require 'redmine/scm/adapters/abstract_adapter' - -module Redmine - module Scm - module Adapters - class GitAdapter < AbstractAdapter - SCM_GIT_REPORT_LAST_COMMIT = true - - # Git executable name - GIT_BIN = OpenProject::Configuration['scm_git_command'] || 'git' - - # raised if scm command exited with error, e.g. unknown revision. - class ScmCommandAborted < CommandFailed; end - - class << self - def client_command - @@bin ||= GIT_BIN - end - - def sq_bin - @@sq_bin ||= shell_quote(GIT_BIN) - end - - def client_version - @@client_version ||= (scm_command_version || []) - end - - def client_available - !client_version.empty? - end - - def scm_command_version - scm_version = scm_version_from_command_line.dup - if scm_version.respond_to?(:force_encoding) - scm_version.force_encoding('ASCII-8BIT') - end - if m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)}) - m[2].scan(%r{\d+}).map(&:to_i) - end - end - - def scm_version_from_command_line - shellout("#{sq_bin} --version --no-color") { |io| io.read }.to_s - end - end - - def initialize(url, root_url = nil, login = nil, password = nil, path_encoding = nil) - super - @path_encoding = path_encoding || 'UTF-8' - @flag_report_last_commit = SCM_GIT_REPORT_LAST_COMMIT - end - - def info - Info.new(root_url: url, lastrev: lastrev('', nil)) - rescue - nil - end - - def branches - return @branches if @branches - @branches = [] - cmd_args = %w|branch --no-color| - scm_cmd(*cmd_args) do |io| - io.each_line do |line| - @branches << line.match('\s*\*?\s*(.*)$')[1] - end - end - @branches.sort! - rescue ScmCommandAborted - nil - end - - def tags - return @tags if @tags - cmd_args = %w|tag| - scm_cmd(*cmd_args) do |io| - @tags = io.readlines.sort!.map(&:strip) - end - rescue ScmCommandAborted - nil - end - - def default_branch - bras = branches - return nil if bras.nil? - bras.include?('master') ? 'master' : bras.first - end - - def entries(path = nil, identifier = nil) - path ||= '' - p = scm_encode(@path_encoding, 'UTF-8', path) - entries = Entries.new - cmd_args = %w|ls-tree -l| - cmd_args << "HEAD:#{p}" if identifier.nil? - cmd_args << "#{identifier}:#{p}" if identifier - scm_cmd(*cmd_args) do |io| - io.each_line do |line| - e = line.chomp.to_s - if e =~ /^\d+\s+(\w+)\s+([0-9a-f]{40})\s+([0-9-]+)\t(.+)$/ - type = $1 - sha = $2 - size = $3 - name = $4 - if name.respond_to?(:force_encoding) - name.force_encoding(@path_encoding) - end - full_path = p.empty? ? name : "#{p}/#{name}" - n = scm_encode('UTF-8', @path_encoding, name) - full_p = scm_encode('UTF-8', @path_encoding, full_path) - entries << Entry.new(name: n, - path: full_p, - kind: (type == 'tree') ? 'dir' : 'file', - size: (type == 'tree') ? nil : size, - lastrev: @flag_report_last_commit ? lastrev(full_path, identifier) : Revision.new - ) unless entries.detect { |entry| entry.name == name } - end - end - end - entries.sort_by_name - rescue ScmCommandAborted - nil - end - - def lastrev(path, rev) - return nil if path.nil? - cmd_args = %w|log --no-color --encoding=UTF-8 --date=iso --pretty=fuller --no-merges -n 1| - cmd_args << rev if rev - cmd_args << '--' << path unless path.empty? - lines = [] - scm_cmd(*cmd_args) do |io| lines = io.readlines end - begin - id = lines[0].split[1] - author = lines[1].match('Author:\s+(.*)$')[1] - time = Time.parse(lines[4].match('CommitDate:\s+(.*)$')[1]) - - Revision.new( - identifier: id, - scmid: id, - author: author, - time: time, - message: nil, - paths: nil - ) - rescue NoMethodError => e - logger.error("The revision '#{path}' has a wrong format") - return nil - end - rescue ScmCommandAborted - nil - end - - def revisions(path, identifier_from, identifier_to, options = {}) - revisions = Revisions.new - cmd_args = %w|log --no-color --encoding=UTF-8 --raw --date=iso --pretty=fuller| - cmd_args << '--reverse' if options[:reverse] - cmd_args << '--all' if options[:all] - cmd_args << '-n' << "#{options[:limit].to_i}" if options[:limit] - from_to = '' - from_to << "#{identifier_from}.." if identifier_from - from_to << "#{identifier_to}" if identifier_to - cmd_args << from_to if !from_to.empty? - cmd_args << "--since=#{options[:since].strftime('%Y-%m-%d %H:%M:%S')}" if options[:since] - cmd_args << '--' << scm_encode(@path_encoding, 'UTF-8', path) if path && !path.empty? - - scm_cmd *cmd_args do |io| - files = [] - changeset = {} - parsing_descr = 0 # 0: not parsing desc or files, 1: parsing desc, 2: parsing files - - io.each_line do |line| - if line =~ /^commit ([0-9a-f]{40})$/ - key = 'commit' - value = $1 - if parsing_descr == 1 || parsing_descr == 2 - parsing_descr = 0 - revision = Revision.new( - identifier: changeset[:commit], - scmid: changeset[:commit], - author: changeset[:author], - time: Time.parse(changeset[:date]), - message: changeset[:description], - paths: files - ) - if block_given? - yield revision - else - revisions << revision - end - changeset = {} - files = [] - end - changeset[:commit] = $1 - elsif (parsing_descr == 0) && line =~ /^(\w+):\s*(.*)$/ - key = $1 - value = $2 - if key == 'Author' - changeset[:author] = value - elsif key == 'CommitDate' - changeset[:date] = value - end - elsif (parsing_descr == 0) && line.chomp.to_s == '' - parsing_descr = 1 - changeset[:description] = '' - elsif (parsing_descr == 1 || parsing_descr == 2) && line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\t(.+)$/ - parsing_descr = 2 - fileaction = $1 - filepath = $2 - p = scm_encode('UTF-8', @path_encoding, filepath) - files << { action: fileaction, path: p } - elsif (parsing_descr == 1 || parsing_descr == 2) && line =~ /^:\d+\s+\d+\s+[0-9a-f.]+\s+[0-9a-f.]+\s+(\w)\d+\s+(\S+)\t(.+)$/ - parsing_descr = 2 - fileaction = $1 - filepath = $3 - p = scm_encode('UTF-8', @path_encoding, filepath) - files << { action: fileaction, path: p } - elsif (parsing_descr == 1) && line.chomp.to_s == '' - parsing_descr = 2 - elsif (parsing_descr == 1) - changeset[:description] << line[4..-1] - end - end - - if changeset[:commit] - revision = Revision.new( - identifier: changeset[:commit], - scmid: changeset[:commit], - author: changeset[:author], - time: Time.parse(changeset[:date]), - message: changeset[:description], - paths: files - ) - - if block_given? - yield revision - else - revisions << revision - end - end - end - revisions - rescue ScmCommandAborted - revisions - end - - def diff(path, identifier_from, identifier_to = nil) - path ||= '' - cmd_args = [] - if identifier_to - cmd_args << 'diff' << '--no-color' << identifier_to << identifier_from - else - cmd_args << 'show' << '--no-color' << identifier_from - end - cmd_args << '--' << scm_encode(@path_encoding, 'UTF-8', path) unless path.empty? - diff = [] - scm_cmd *cmd_args do |io| - io.each_line do |line| - diff << line - end - end - diff - rescue ScmCommandAborted - nil - end - - def annotate(path, identifier = nil) - identifier = 'HEAD' if identifier.blank? - cmd_args = %w|blame| - cmd_args << '-p' << identifier << '--' << scm_encode(@path_encoding, 'UTF-8', path) - blame = Annotate.new - content = nil - scm_cmd(*cmd_args) do |io| io.binmode; content = io.read end - # git annotates binary files - if content.respond_to?('is_binary_data?') && content.is_binary_data? # Ruby 1.8.x and <1.9.2 - return nil - elsif content.respond_to?(:force_encoding) && (content.dup.force_encoding('UTF-8') != content.dup.force_encoding('BINARY')) # Ruby 1.9.2 - # TODO: need to handle edge cases of non-binary content that isn't UTF-8 - return nil - end - identifier = '' - # git shows commit author on the first occurrence only - authors_by_commit = {} - content.split("\n").each do |line| - if line =~ /^([0-9a-f]{39,40})\s.*/ - identifier = $1 - elsif line =~ /^author (.+)/ - authors_by_commit[identifier] = $1.strip - elsif line =~ /^\t(.*)/ - blame.add_line($1, Revision.new( - identifier: identifier, - author: authors_by_commit[identifier])) - identifier = '' - author = '' - end - end - blame - rescue ScmCommandAborted - nil - end - - def cat(path, identifier = nil) - if identifier.nil? - identifier = 'HEAD' - end - cmd_args = %w|show --no-color| - cmd_args << "#{identifier}:#{scm_encode(@path_encoding, 'UTF-8', path)}" - cat = nil - scm_cmd(*cmd_args) do |io| - io.binmode - cat = io.read - end - cat - rescue ScmCommandAborted - nil - end - - class Revision < Redmine::Scm::Adapters::Revision - # Returns the readable identifier - def format_identifier - identifier[0, 8] - end - end - - def scm_cmd(*args, &block) - repo_path = root_url || url - full_args = [GIT_BIN, '--git-dir', repo_path] - if self.class.client_version_above?([1, 7, 2]) - full_args << '-c' << 'core.quotepath=false' - end - full_args += args - ret = shellout(full_args.map { |e| shell_quote e.to_s }.join(' '), &block) - if $? && $?.exitstatus != 0 - raise ScmCommandAborted, "git exited with non-zero status: #{$?.exitstatus}" - end - ret - end - private :scm_cmd - end - end - end -end diff --git a/lib/redmine/scm/adapters/subversion_adapter.rb b/lib/redmine/scm/adapters/subversion_adapter.rb deleted file mode 100644 index cfaed779001..00000000000 --- a/lib/redmine/scm/adapters/subversion_adapter.rb +++ /dev/null @@ -1,293 +0,0 @@ -#-- encoding: UTF-8 -#-- copyright -# OpenProject is a project management system. -# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See doc/COPYRIGHT.rdoc for more details. -#++ - -require 'redmine/scm/adapters/abstract_adapter' -require 'uri' - -module Redmine - module Scm - module Adapters - class SubversionAdapter < AbstractAdapter - # SVN executable name - SVN_BIN = OpenProject::Configuration['scm_subversion_command'] || 'svn' - - class << self - def client_command - @@bin ||= SVN_BIN - end - - def sq_bin - @@sq_bin ||= shell_quote(SVN_BIN) - end - - def client_version - @@client_version ||= (svn_binary_version || []) - end - - def client_available - !client_version.empty? - end - - def svn_binary_version - scm_version = scm_version_from_command_line.dup - if scm_version.respond_to?(:force_encoding) - scm_version.force_encoding('ASCII-8BIT') - end - if m = scm_version.match(%r{\A(.*?)((\d+\.)+\d+)}) - m[2].scan(%r{\d+}).map(&:to_i) - end - end - - def scm_version_from_command_line - shellout("#{sq_bin} --version") { |io| io.read }.to_s - end - end - - # Get info about the svn repository - def info - cmd = "#{self.class.sq_bin} info --xml #{target}" - cmd << credentials_string - info = nil - shellout(cmd) do |io| - output = io.read - if output.respond_to?(:force_encoding) - output.force_encoding('UTF-8') - end - begin - doc = ActiveSupport::XmlMini.parse(output) - # root_url = doc.elements["info/entry/repository/root"].text - info = Info.new(root_url: doc['info']['entry']['repository']['root']['__content__'], - lastrev: Revision.new( - identifier: doc['info']['entry']['commit']['revision'], - time: Time.parse(doc['info']['entry']['commit']['date']['__content__']).localtime, - author: (doc['info']['entry']['commit']['author'] ? doc['info']['entry']['commit']['author']['__content__'] : '') - ) - ) - rescue - end - end - return nil if $? && $?.exitstatus != 0 - info - rescue CommandFailed - return nil - end - - # Returns an Entries collection - # or nil if the given path doesn't exist in the repository - def entries(path = nil, identifier = nil) - path ||= '' - identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD' - entries = Entries.new - cmd = "#{self.class.sq_bin} list --xml #{target(path)}@#{identifier}" - cmd << credentials_string - shellout(cmd) do |io| - output = io.read - if output.respond_to?(:force_encoding) - output.force_encoding('UTF-8') - end - begin - doc = ActiveSupport::XmlMini.parse(output) - each_xml_element(doc['lists']['list'], 'entry') do |entry| - commit = entry['commit'] - commit_date = commit['date'] - # Skip directory if there is no commit date (usually that - # means that we don't have read access to it) - next if entry['kind'] == 'dir' && commit_date.nil? - name = entry['name']['__content__'] - entries << Entry.new(name: URI.unescape(name), - path: ((path.empty? ? '' : "#{path}/") + name), - kind: entry['kind'], - size: ((s = entry['size']) ? s['__content__'].to_i : nil), - lastrev: Revision.new( - identifier: commit['revision'], - time: Time.parse(commit_date['__content__'].to_s).localtime, - author: ((a = commit['author']) ? a['__content__'] : nil) - ) - ) - end - rescue => e - logger.error("Error parsing svn output: #{e.message}") - logger.error("Output was:\n #{output}") - end - end - return nil if $? && $?.exitstatus != 0 - logger.debug("Found #{entries.size} entries in the repository for #{target(path)}") if logger && logger.debug? - entries.sort_by_name - end - - def properties(path, identifier = nil) - # proplist xml output supported in svn 1.5.0 and higher - return nil unless self.class.client_version_above?([1, 5, 0]) - - identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD' - cmd = "#{self.class.sq_bin} proplist --verbose --xml #{target(path)}@#{identifier}" - cmd << credentials_string - properties = {} - shellout(cmd) do |io| - output = io.read - if output.respond_to?(:force_encoding) - output.force_encoding('UTF-8') - end - begin - doc = ActiveSupport::XmlMini.parse(output) - each_xml_element(doc['properties']['target'], 'property') do |property| - properties[property['name']] = property['__content__'].to_s - end - rescue - end - end - return nil if $? && $?.exitstatus != 0 - properties - end - - def revisions(path = nil, identifier_from = nil, identifier_to = nil, options = {}) - path ||= '' - identifier_from = (identifier_from && identifier_from.to_i > 0) ? identifier_from.to_i : 'HEAD' - identifier_to = (identifier_to && identifier_to.to_i > 0) ? identifier_to.to_i : 1 - revisions = Revisions.new - cmd = "#{self.class.sq_bin} log --xml -r #{identifier_from}:#{identifier_to}" - cmd << credentials_string - cmd << ' --verbose ' if options[:with_paths] - cmd << " --limit #{options[:limit].to_i}" if options[:limit] - cmd << ' ' + target(path) - shellout(cmd) do |io| - output = io.read - if output.respond_to?(:force_encoding) - output.force_encoding('UTF-8') - end - begin - doc = ActiveSupport::XmlMini.parse(output) - each_xml_element(doc['log'], 'logentry') do |logentry| - paths = [] - each_xml_element(logentry['paths'], 'path') do |path| - paths << { action: path['action'], - path: path['__content__'], - from_path: path['copyfrom-path'], - from_revision: path['copyfrom-rev'] - } - end if logentry['paths'] && logentry['paths']['path'] - paths.sort! do |x, y| x[:path] <=> y[:path] end - - revisions << Revision.new(identifier: logentry['revision'], - author: (logentry['author'] ? logentry['author']['__content__'] : ''), - time: Time.parse(logentry['date']['__content__'].to_s).localtime, - message: logentry['msg']['__content__'], - paths: paths - ) - end - rescue - end - end - return nil if $? && $?.exitstatus != 0 - revisions - end - - def diff(path, identifier_from, identifier_to = nil, _type = 'inline') - path ||= '' - identifier_from = (identifier_from and identifier_from.to_i > 0) ? identifier_from.to_i : '' - - identifier_to = (identifier_to and identifier_to.to_i > 0) ? identifier_to.to_i : (identifier_from.to_i - 1) - - cmd = "#{self.class.sq_bin} diff -r " - cmd << "#{identifier_to}:" - cmd << "#{identifier_from}" - cmd << " #{target(path)}@#{identifier_from}" - cmd << credentials_string - diff = [] - shellout(cmd) do |io| - io.each_line do |line| - diff << line - end - end - return nil if $? && $?.exitstatus != 0 - diff - end - - def cat(path, identifier = nil) - identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD' - cmd = "#{self.class.sq_bin} cat #{target(path)}@#{identifier}" - cmd << credentials_string - cat = nil - shellout(cmd) do |io| - io.binmode - cat = io.read - end - return nil if $? && $?.exitstatus != 0 - cat - end - - def annotate(path, identifier = nil) - identifier = (identifier and identifier.to_i > 0) ? identifier.to_i : 'HEAD' - cmd = "#{self.class.sq_bin} blame #{target(path)}@#{identifier}" - cmd << credentials_string - blame = Annotate.new - shellout(cmd) do |io| - io.each_line do |line| - next unless line =~ %r{^\s*(\d+)\s*(\S+)\s(.*)$} - blame.add_line($3.rstrip, Revision.new(identifier: $1.to_i, author: $2.strip)) - end - end - return nil if $? && $?.exitstatus != 0 - blame - end - - private - - def credentials_string - str = '' - str << " --username #{shell_quote(@login)}" unless @login.blank? - str << " --password #{shell_quote(@password)}" unless @login.blank? || @password.blank? - str << ' --no-auth-cache --non-interactive' - str - end - - # Helper that iterates over the child elements of a xml node - # MiniXml returns a hash when a single child is found or an array of hashes for multiple children - def each_xml_element(node, name) - if node && node[name] - if node[name].is_a?(Hash) - yield node[name] - else - node[name].each do |element| - yield element - end - end - end - end - - def target(path = '') - base = path.match(/\A\//) ? root_url : url - uri = "#{base}/#{path}" - uri = URI.escape(URI.escape(uri), '[]') - shell_quote(uri.gsub(/[?<>\*]/, '')) - end - end - end - end -end diff --git a/lib/tasks/testing.rake b/lib/tasks/testing.rake index 913cc4d8a1a..adfc2d5243e 100644 --- a/lib/tasks/testing.rake +++ b/lib/tasks/testing.rake @@ -48,20 +48,16 @@ namespace :test do FileUtils.mkdir_p Rails.root + '/tmp/test' end - supported_scms = [:subversion, :git, :filesystem] + supported_scms = [:subversion, :git] desc 'Creates a test subversion repository' - task subversion: :create_dir do - repo_path = 'tmp/test/subversion_repository' - system "svnadmin create #{repo_path}" - system "gunzip < spec/fixtures/repositories/subversion_repository.dump.gz | svnadmin load #{repo_path}" - end - - (supported_scms - [:subversion]).each do |scm| + supported_scms.each do |scm| desc "Creates a test #{scm} repository" task scm => :create_dir do + repo_path = File.join(Rails.root, "tmp/test/#{scm}_repository") + FileUtils.mkdir_p repo_path # system "gunzip < spec/fixtures/repositories/#{scm}_repository.tar.gz | tar -xv -C tmp/test" - system "tar -xvz -C tmp/test -f spec/fixtures/repositories/#{scm}_repository.tar.gz" + system "tar -xvz -C #{repo_path} -f spec/fixtures/repositories/#{scm}_repository.tar.gz" end end diff --git a/spec/app/services/scm/create_managed_repository_service_spec.rb b/spec/app/services/scm/create_managed_repository_service_spec.rb new file mode 100644 index 00000000000..f0cafa6d48c --- /dev/null +++ b/spec/app/services/scm/create_managed_repository_service_spec.rb @@ -0,0 +1,137 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. + +require 'spec_helper' + +describe Scm::CreateManagedRepositoryService do + let(:user) { FactoryGirl.build(:user) } + let(:project) { FactoryGirl.build(:project) } + + let(:repository) { FactoryGirl.build(:repository_subversion) } + subject(:service) { Scm::CreateManagedRepositoryService.new(repository) } + + let(:config) { {} } + + before do + allow(OpenProject::Configuration).to receive(:[]).and_call_original + allow(OpenProject::Configuration).to receive(:[]).with('scm').and_return(config) + end + + shared_examples 'does not create a filesystem repository' do + it 'does not create a filesystem repository' do + expect(repository.managed?).to be false + expect(service.call).to be false + end + end + + context 'with no managed configuration' do + it_behaves_like 'does not create a filesystem repository' + end + + context 'with managed repository' do + # Must not .create a managed repository, or it will call this service itself! + let(:repository) { + repo = Repository::Subversion.new(scm_type: :managed) + repo.project = project + repo + } + + context 'but no managed config' do + it 'does not create a filesystem repository' do + expect(repository.managed?).to be true + expect(service.call).to be false + end + end + end + + context 'with managed config' do + include_context 'with tmpdir' + let(:config) { + { + Subversion: { manages: File.join(tmpdir, 'svn') }, + Git: { manages: File.join(tmpdir, 'git') } + } + } + + let(:repository) { + repo = Repository::Subversion.new(scm_type: :managed) + repo.project = project + repo.configure(:managed, nil) + repo + } + + before do + allow_any_instance_of(Scm::CreateRepositoryJob) + .to receive(:repository).and_return(repository) + end + + it 'creates the repository' do + expect(service.call).to be true + expect(File.directory?(repository.root_url)).to be true + end + + context 'with pre-existing path on filesystem' do + before do + allow(File).to receive(:directory?).and_return(true) + end + + it 'does not create the repository' do + expect(service.call).to be false + expect(service.localized_rejected_reason) + .to eq(I18n.t('repositories.errors.exists_on_filesystem')) + end + end + + context 'with a permission error occuring in the Job' do + before do + allow(Scm::CreateRepositoryJob) + .to receive(:new).and_raise(Errno::EACCES) + end + + it 'returns the correct error' do + expect(service.call).to be false + expect(service.localized_rejected_reason) + .to eq(I18n.t('repositories.errors.path_permission_failed', + path: repository.root_url)) + end + end + + context 'with an OS error occuring in the Job' do + before do + allow(Scm::CreateRepositoryJob) + .to receive(:new).and_raise(Errno::ENOENT) + end + + it 'returns the correct error' do + expect(service.call).to be false + expect(service.localized_rejected_reason) + .to include('An error occurred while creating the repository on filesystem') + end + end + end +end diff --git a/spec/app/services/scm/delete_managed_repository_service_spec.rb b/spec/app/services/scm/delete_managed_repository_service_spec.rb new file mode 100644 index 00000000000..d867d4ceadd --- /dev/null +++ b/spec/app/services/scm/delete_managed_repository_service_spec.rb @@ -0,0 +1,112 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. + +require 'spec_helper' + +describe Scm::DeleteManagedRepositoryService do + let(:user) { FactoryGirl.build(:user) } + let(:project) { FactoryGirl.build(:project) } + + let(:repository) { FactoryGirl.build(:repository_subversion) } + subject(:service) { Scm::DeleteManagedRepositoryService.new(repository) } + + let(:config) { {} } + + before do + allow(OpenProject::Configuration).to receive(:[]).and_call_original + allow(OpenProject::Configuration).to receive(:[]).with('scm').and_return(config) + end + + shared_examples 'does not delete the repository' do + it 'does not delete the repository' do + expect(repository.managed?).to be false + expect(service.call).to be false + end + end + + context 'with no managed configuration' do + it_behaves_like 'does not delete the repository' + end + + context 'with managed repository, but no config' do + let(:repository) { FactoryGirl.build(:repository_subversion, scm_type: :managed) } + + it 'does allow to delete the repository' do + expect(repository.managed?).to be true + expect(service.call).to be true + end + end + + context 'with managed repository and managed config' do + include_context 'with tmpdir' + let(:config) { + { + Subversion: { manages: File.join(tmpdir, 'svn') }, + Git: { manages: File.join(tmpdir, 'git') } + } + } + + let(:repository) { + repo = Repository::Subversion.new(scm_type: :managed) + repo.project = project + repo.configure(:managed, nil) + + repo.save! + repo + } + + before do + allow_any_instance_of(Scm::CreateRepositoryJob) + .to receive(:repository).and_return(repository) + end + + it 'deletes the repository' do + expect(File.directory?(repository.root_url)).to be true + expect(service.call).to be true + expect(File.directory?(repository.root_url)).to be false + end + + context 'and parent project' do + let(:parent) { FactoryGirl.create(:project) } + let(:project) { FactoryGirl.create(:project, parent: parent) } + let(:repo_path) { + Pathname.new(File.join(tmpdir, 'svn', project.identifier)) + } + + it 'does not delete anything but the repository itself' do + expect(service.call).to be true + path = Pathname.new(repository.root_url) + expect(path).to eq(repo_path) + + expect(path.exist?).to be false + expect(path.parent.exist?).to be true + expect(path.parent.to_s).to eq(repository.class.managed_root) + end + end + end +end diff --git a/spec/app/services/scm/repository_factory_service_spec.rb b/spec/app/services/scm/repository_factory_service_spec.rb new file mode 100644 index 00000000000..3c7f474d433 --- /dev/null +++ b/spec/app/services/scm/repository_factory_service_spec.rb @@ -0,0 +1,133 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. + +require 'spec_helper' + +describe Scm::RepositoryFactoryService do + let(:user) { FactoryGirl.build(:user) } + let(:project) { FactoryGirl.build(:project) } + + let(:enabled_scms) { ['Subversion', 'Git'] } + + let(:params_hash) { {} } + let(:params) { ActionController::Parameters.new params_hash } + + subject(:service) { Scm::RepositoryFactoryService.new(project, params) } + + before do + allow(Setting).to receive(:enabled_scm).and_return(enabled_scms) + end + + context 'with empty hash' do + it 'should not build a repository' do + expect(service.build_temporary).not_to be true + expect(service.repository).to be_nil + end + end + + context 'with valid vendor' do + let(:params_hash) { + { scm_vendor: 'Subversion' } + } + + it 'should allow temporary build repository' do + expect(service.build_temporary).to be true + expect(service.repository).not_to be_nil + end + + it 'should not allow to persist a repository' do + expect { service.build_and_save } + .to raise_error(ActionController::ParameterMissing) + + expect(service.repository).to be_nil + end + end + + context 'with invalid vendor' do + let(:params_hash) { + { scm_vendor: 'NotSubversion', scm_type: 'foo' } + } + + it 'should not allow to temporary build repository' do + expect { service.build_temporary }.not_to raise_error + + expect(service.repository).to be_nil + expect(service.build_error).to include('The SCM vendor NotSubversion is disabled') + end + + it 'should not allow to persist a repository' do + expect { service.build_temporary }.not_to raise_error + + expect(service.repository).to be_nil + expect(service.build_error).to include('The SCM vendor NotSubversion is disabled') + end + end + + context 'with vendor and type' do + let(:params_hash) { + { scm_vendor: 'Subversion', scm_type: 'existing' } + } + + it 'should not allow to persist a repository without URL' do + expect(service.build_and_save).not_to be true + + expect(service.repository).to be_nil + expect(service.build_error).to include("URL can't be blank") + end + end + + context 'with invalid hash' do + let(:params_hash) { + { + scm_vendor: 'Subversion', scm_type: 'existing', + repository: { url: '/tmp/foo.svn' } + } + } + + it 'should not allow to persist a repository URL' do + expect(service.build_and_save).not_to be true + + expect(service.repository).to be_nil + expect(service.build_error).to include('URL is invalid') + end + end + + context 'with valid hash' do + let(:params_hash) { + { + scm_vendor: 'Subversion', scm_type: 'existing', + repository: { url: 'file:///tmp/foo.svn' } + } + } + + it 'should allow to persist a repository without URL' do + expect(service.build_and_save).to be true + expect(service.repository).to be_kind_of(Repository::Subversion) + end + end +end diff --git a/spec/controllers/messages_controller_spec.rb b/spec/controllers/messages_controller_spec.rb index bcfca5f439c..9be3267f587 100644 --- a/spec/controllers/messages_controller_spec.rb +++ b/spec/controllers/messages_controller_spec.rb @@ -69,7 +69,7 @@ describe MessagesController, type: :controller do describe '#journal' do let(:attachment_id) { "attachments_#{Message.last.attachments.first.id}" } - subject { Message.last.journals.last.changed_data } + subject { Message.last.journals.last.details } it { is_expected.to have_key attachment_id } @@ -142,13 +142,13 @@ describe MessagesController, type: :controller do end describe '#key' do - subject { message.journals.last.changed_data } + subject { message.journals.last.details } it { is_expected.to have_key attachment_id } end describe '#value' do - subject { message.journals.last.changed_data[attachment_id].last } + subject { message.journals.last.details[attachment_id].last } it { is_expected.to eq(filename) } end @@ -179,13 +179,13 @@ describe MessagesController, type: :controller do let(:attachment_id) { "attachments_#{attachment.id}" } describe '#key' do - subject { message.journals.last.changed_data } + subject { message.journals.last.details } it { is_expected.to have_key attachment_id } end describe '#value' do - subject { message.journals.last.changed_data[attachment_id].first } + subject { message.journals.last.details[attachment_id].first } it { is_expected.to eq(filename) } end diff --git a/spec/controllers/repositories_controller_spec.rb b/spec/controllers/repositories_controller_spec.rb index ad72a5552a6..4172d6fcf34 100644 --- a/spec/controllers/repositories_controller_spec.rb +++ b/spec/controllers/repositories_controller_spec.rb @@ -38,19 +38,17 @@ describe RepositoriesController, type: :controller do FactoryGirl.create(:user, member_in_project: project, member_through_role: role) } - let(:repository) { FactoryGirl.create(:repository, project: project) } - - let(:user) do - FactoryGirl.create(:user, member_in_project: project, - member_through_role: role) - end + let (:url) { 'file:///tmp/something/does/not/exist.svn' } let(:repository) do - allow(Setting).to receive(:enabled_scm).and_return(['Filesystem']) - repo = FactoryGirl.build_stubbed(:repository, + allow(Setting).to receive(:enabled_scm).and_return(['Subversion']) + repo = FactoryGirl.build_stubbed(:repository_subversion, + scm_type: 'local', + url: url, project: project) allow(repo).to receive(:default_branch).and_return('master') allow(repo).to receive(:branches).and_return(['master']) + allow(repo).to receive(:save).and_return(true) repo end @@ -60,7 +58,7 @@ describe RepositoriesController, type: :controller do allow(project).to receive(:repository).and_return(repository) end - describe '#edit' do + describe 'manages the repository' do let(:role) { FactoryGirl.create(:role, permissions: [:manage_repository]) } before do @@ -68,85 +66,134 @@ describe RepositoriesController, type: :controller do allow(controller).to receive(:authorize).and_return(true) end - shared_examples_for 'successful response' do + shared_examples_for 'successful settings response' do it 'is successful' do expect(response).to be_success end it 'renders the template' do - expect(response).to render_template 'projects/settings/repository' + expect(response).to render_template 'repositories/settings/repository_form' end end - context 'GET' do + context 'with #edit' do before do xhr :get, :edit end - it_behaves_like 'successful response' + it_behaves_like 'successful settings response' end - context 'POST' do + context 'with #destroy' do before do - allow(repository).to receive(:save).and_return(true) - - xhr :post, :edit + allow(repository).to receive(:destroy).and_return(true) + xhr :delete, :destroy end - it_behaves_like 'successful response' - end - end - - describe 'commits per author graph' do - before do - get :graph, project_id: project.identifier, graph: 'commits_per_author' - end - - context 'requested by an authorized user' do - let(:role) { - FactoryGirl.create(:role, permissions: [:browse_repository, - :view_commit_author_statistics]) - } - - it 'should be successful' do - expect(response).to be_success - end - - it 'should have the right content type' do - expect(response.content_type).to eq('image/svg+xml') + it 'redirects to settings' do + expect(response).to redirect_to(settings_project_path(project, tab: 'repository')) end end - context 'requested by an unauthorized user' do - let(:role) { FactoryGirl.create(:role, permissions: [:browse_repository]) } + context 'with #update' do + before do + xhr :put, :update + end - it 'should return 403' do - expect(response.code).to eq('403') + it_behaves_like 'successful settings response' + end + + context 'with #create' do + before do + xhr :post, + :create, + scm_vendor: 'Subversion', + scm_type: 'local', + url: 'file:///tmp/repo.svn/' + end + + it 'renders a JS redirect' do + path = "\/projects\/#{project.identifier}/settings\/repository" + expect(response.body).to match(/window\.location = '#{path}'/) end end end - describe 'stats' do + describe 'with empty repository' do + let(:role) { FactoryGirl.create(:role, permissions: [:browse_repository]) } before do - get :stats, project_id: project.identifier + allow(repository.scm) + .to receive(:check_availability!) + .and_raise(OpenProject::Scm::Exceptions::ScmEmpty) end - describe 'requested by a user with view_commit_author_statistics permission' do - let(:role) { - FactoryGirl.create(:role, permissions: [:browse_repository, - :view_commit_author_statistics]) - } - - it 'show the commits per author graph' do - expect(assigns(:show_commits_per_author)).to eq(true) + context 'with #show' do + before do + get :show, project_id: project.identifier + end + it 'renders an empty warning view' do + expect(response).to render_template 'repositories/empty' + expect(response.code).to eq('200') end end + end - describe 'requested by a user without view_commit_author_statistics permission' do - let(:role) { FactoryGirl.create(:role, permissions: [:browse_repository]) } + describe 'with filesystem repository' do + with_filesystem_repository('subversion', 'svn') do |repo_dir| + let(:url) { "file://#{repo_dir}" } - it 'should NOT show the commits per author graph' do - expect(assigns(:show_commits_per_author)).to eq(false) + describe 'commits per author graph' do + before do + get :graph, project_id: project.identifier, graph: 'commits_per_author' + end + + context 'requested by an authorized user' do + let(:role) { + FactoryGirl.create(:role, permissions: [:browse_repository, + :view_commit_author_statistics]) + } + + it 'should be successful' do + expect(response).to be_success + end + + it 'should have the right content type' do + expect(response.content_type).to eq('image/svg+xml') + end + end + + context 'requested by an unauthorized user' do + let(:role) { FactoryGirl.create(:role, permissions: [:browse_repository]) } + + it 'should return 403' do + expect(response.code).to eq('403') + end + end + end + + describe 'stats' do + before do + get :stats, project_id: project.identifier + end + + describe 'requested by a user with view_commit_author_statistics permission' do + let(:role) { + FactoryGirl.create(:role, permissions: [:browse_repository, + :view_commit_author_statistics]) + } + + it 'show the commits per author graph' do + expect(assigns(:show_commits_per_author)).to eq(true) + end + end + + describe 'requested by a user without view_commit_author_statistics permission' do + let(:role) { FactoryGirl.create(:role, permissions: [:browse_repository]) } + + it 'should NOT show the commits per author graph' do + expect(assigns(:show_commits_per_author)).to eq(false) + end + end end end end diff --git a/spec/controllers/sys_controller_spec.rb b/spec/controllers/sys_controller_spec.rb index 0df73b3ac8c..8a3eca8206f 100644 --- a/spec/controllers/sys_controller_spec.rb +++ b/spec/controllers/sys_controller_spec.rb @@ -38,9 +38,10 @@ module OpenProjectRepositoryAuthenticationSpecs let(:guest_role) { FactoryGirl.create(:role, permissions: []) } let(:valid_user_password) { 'Top Secret Password' } let(:valid_user) { - FactoryGirl.create(:user, login: 'johndoe', - password: valid_user_password, - password_confirmation: valid_user_password) + FactoryGirl.create(:user, + login: 'johndoe', + password: valid_user_password, + password_confirmation: valid_user_password) } before(:each) do @@ -48,9 +49,10 @@ module OpenProjectRepositoryAuthenticationSpecs DeletedUser.first # creating it first in order to avoid problems with should_receive random_project = FactoryGirl.create(:project, is_public: false) - @member = FactoryGirl.create(:member, user: valid_user, - roles: [browse_role], - project: random_project) + @member = FactoryGirl.create(:member, + user: valid_user, + roles: [browse_role], + project: random_project) allow(Setting).to receive(:sys_api_key).and_return('12345678') allow(Setting).to receive(:sys_api_enabled?).and_return(true) allow(Setting).to receive(:repository_authentication_caching_enabled?).and_return(true) @@ -60,7 +62,7 @@ module OpenProjectRepositoryAuthenticationSpecs before(:each) do @key = Setting.sys_api_key request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(valid_user.login, valid_user_password) - post 'repo_auth', key: @key, repository: 'without-access', method: 'GET' + post 'repo_auth', key: @key, repository: 'without-access', method: 'GET' end it 'should respond 403 not allowed' do @@ -73,19 +75,20 @@ module OpenProjectRepositoryAuthenticationSpecs before(:each) do @key = Setting.sys_api_key @project = FactoryGirl.create(:project, is_public: false) - @member = FactoryGirl.create(:member, user: valid_user, - roles: [browse_role], - project: @project) + @member = FactoryGirl.create(:member, + user: valid_user, + roles: [browse_role], + project: @project) request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(valid_user.login, valid_user_password) end it 'should respond 200 okay dokay for GET' do - post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET' + post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET' expect(response.code).to eq('200') end it 'should respond 403 not allowed for POST' do - post 'repo_auth', key: @key, repository: @project.identifier, method: 'POST' + post 'repo_auth', key: @key, repository: @project.identifier, method: 'POST' expect(response.code).to eq('403') end end @@ -94,20 +97,21 @@ module OpenProjectRepositoryAuthenticationSpecs before(:each) do @key = Setting.sys_api_key @project = FactoryGirl.create(:project, is_public: false) - @member = FactoryGirl.create(:member, user: valid_user, - roles: [commit_role], - project: @project) + @member = FactoryGirl.create(:member, + user: valid_user, + roles: [commit_role], + project: @project) valid_user.save request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(valid_user.login, valid_user_password) end it 'should respond 200 okay dokay for GET' do - post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET' + post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET' expect(response.code).to eq('200') end it 'should respond 200 okay dokay for POST' do - post 'repo_auth', key: @key, repository: @project.identifier, method: 'POST' + post 'repo_auth', key: @key, repository: @project.identifier, method: 'POST' expect(response.code).to eq('200') end end @@ -116,11 +120,12 @@ module OpenProjectRepositoryAuthenticationSpecs before(:each) do @key = Setting.sys_api_key @project = FactoryGirl.create(:project, is_public: false) - @member = FactoryGirl.create(:member, user: valid_user, - roles: [commit_role], - project: @project) + @member = FactoryGirl.create(:member, + user: valid_user, + roles: [commit_role], + project: @project) request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(valid_user.login, valid_user_password + 'made invalid') - post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET' + post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET' end it 'should respond 401 auth required' do @@ -133,7 +138,7 @@ module OpenProjectRepositoryAuthenticationSpecs @key = Setting.sys_api_key @project = FactoryGirl.create(:project, is_public: false) request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(valid_user.login, valid_user_password) - post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET' + post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET' end it 'should respond 403 not allowed' do @@ -147,12 +152,13 @@ module OpenProjectRepositoryAuthenticationSpecs @project = FactoryGirl.create(:project, is_public: true) random_project = FactoryGirl.create(:project, is_public: false) - @member = FactoryGirl.create(:member, user: valid_user, - roles: [browse_role], - project: random_project) + @member = FactoryGirl.create(:member, + user: valid_user, + roles: [browse_role], + project: random_project) request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(valid_user.login, valid_user_password) - post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET' + post 'repo_auth', key: @key, repository: @project.identifier, method: 'GET' end it 'should respond 200 OK' do @@ -163,7 +169,7 @@ module OpenProjectRepositoryAuthenticationSpecs describe '#repo_auth', 'for invalid credentials' do before(:each) do @key = Setting.sys_api_key - post 'repo_auth', key: @key, repository: 'any-repo', method: 'GET' + post 'repo_auth', key: @key, repository: 'any-repo', method: 'GET' end it 'should respond 401 auth required' do @@ -179,14 +185,14 @@ module OpenProjectRepositoryAuthenticationSpecs it 'should respond 403 for valid username/password' do request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(valid_user.login, valid_user_password) - post 'repo_auth', key: @key, repository: 'any-repo', method: 'GET' + post 'repo_auth', key: @key, repository: 'any-repo', method: 'GET' expect(response.code).to eq('403') expect(response.body).to eq('Access denied. Repository management WS is disabled or key is invalid.') end it 'should respond 403 for invalid username/password' do request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials('invalid', 'invalid') - post 'repo_auth', key: @key, repository: 'any-repo', method: 'GET' + post 'repo_auth', key: @key, repository: 'any-repo', method: 'GET' expect(response.code).to eq('403') expect(response.body).to eq('Access denied. Repository management WS is disabled or key is invalid.') end @@ -210,15 +216,13 @@ module OpenProjectRepositoryAuthenticationSpecs end it 'should return the same as user_login for valid creds' do - expect(controller.send(:cached_user_login, valid_user.login, valid_user_password)).to eq( - controller.send(:user_login, valid_user.login, valid_user_password) - ) + expect(controller.send(:cached_user_login, valid_user.login, valid_user_password)) + .to eq(controller.send(:user_login, valid_user.login, valid_user_password)) end it 'should return the same as user_login for invalid creds' do - expect(controller.send(:cached_user_login, 'invalid', 'invalid')).to eq( - controller.send(:user_login, 'invalid', 'invalid') - ) + expect(controller.send(:cached_user_login, 'invalid', 'invalid')) + .to eq(controller.send(:user_login, 'invalid', 'invalid')) end it 'should use cache' do @@ -261,5 +265,142 @@ module OpenProjectRepositoryAuthenticationSpecs end end end + + describe 'update_required_storage' do + let(:force) { nil } + let(:apikey) { Setting.sys_api_key } + let(:last_updated) { nil } + + def request_storage + get 'update_required_storage', key: apikey, id: id, force: force + end + + context 'missing project' do + let(:id) { 1234 } + + it 'returns 404' do + request_storage + expect(response.code).to eq('404') + expect(response.body).to include('Could not find project #1234') + end + end + + context 'available project, but missing repository' do + let(:project) { FactoryGirl.build_stubbed(:project) } + let(:id) { project.id } + before do + allow(Project).to receive(:find).and_return(project) + request_storage + end + + it 'returns 404' do + expect(response.code).to eq('404') + expect(response.body).to include("Project ##{project.id} does not have a repository.") + end + end + + context 'stubbed repository' do + let(:project) { FactoryGirl.build_stubbed(:project) } + let(:id) { project.id } + let(:repository) { + FactoryGirl.build_stubbed(:repository_subversion, url: url, root_url: url) + } + + before do + allow(Project).to receive(:find).and_return(project) + allow(project).to receive(:repository).and_return(repository) + + allow(repository).to receive(:storage_updated_at).and_return(last_updated) + request_storage + end + + context 'local non-existing repository' do + let(:root_url) { '/tmp/does/not/exist/svn/foo.svn' } + let(:url) { "file://#{root_url}" } + + it 'does not have storage available' do + expect(repository.scm.storage_available?).to be false + expect(response.code).to eq('400') + end + end + + context 'remote stubbed repository' do + let(:root_url) { '' } + let(:url) { 'https://foo.example.org/svn/bar' } + + it 'has no storage available' do + request_storage + expect(repository.scm.storage_available?).to be false + expect(response.code).to eq('400') + end + end + end + + context 'local existing repository' do + with_subversion_repository do |repo_dir| + let(:root_url) { repo_dir } + let(:url) { "file://#{root_url}" } + + let(:project) { FactoryGirl.create(:project) } + let(:id) { project.id } + let(:repository) { + FactoryGirl.create(:repository_subversion, project: project, url: url, root_url: url) + } + + before do + allow(Project).to receive(:find).and_return(project) + allow(project).to receive(:repository).and_return(repository) + allow(repository).to receive(:storage_updated_at).and_return(last_updated) + end + + it 'has storage available' do + expect(repository.scm.storage_available?).to be true + end + + context 'storage never updated before' do + it 'updates the storage' do + expect(repository.required_storage_bytes).to be == 0 + request_storage + + repository.reload + expect(repository.required_storage_bytes).to be > 0 + end + end + + context 'outdated storage' do + let(:last_updated) { 2.days.ago } + it 'updates the storage' do + expect(Delayed::Job) + .to receive(:enqueue).with(instance_of(::Scm::StorageUpdaterJob)) + + request_storage + end + end + + context 'valid storage time' do + let(:last_updated) { 10.minutes.ago } + + it 'does not update to storage' do + expect(Delayed::Job) + .not_to receive(:enqueue).with(instance_of(::Scm::StorageUpdaterJob)) + + request_storage + end + end + + context 'valid storage time and force' do + let(:force) { '1' } + let(:last_updated) { 10.minutes.ago } + + it 'does update to storage' do + expect(Delayed::Job) + .to receive(:enqueue).with(instance_of(::Scm::StorageUpdaterJob)) + + request_storage + end + end + end + end + end end end diff --git a/spec/controllers/work_packages/creation_spec.rb b/spec/controllers/work_packages/creation_spec.rb index cf1bbc6db08..18b7b9ef7c1 100644 --- a/spec/controllers/work_packages/creation_spec.rb +++ b/spec/controllers/work_packages/creation_spec.rb @@ -73,7 +73,7 @@ describe WorkPackagesController, type: :controller do # Find the enqueued job responsible for sending the notification # for the creation of the work package. Delayed::Job.all.map(&:payload_object).detect do |job| - if job.is_a? DeliverWorkPackageCreatedJob + if job.is_a? EnqueueWorkPackageNotificationJob job.send(:work_package) == work_package end end @@ -124,10 +124,6 @@ describe WorkPackagesController, type: :controller do it 'is enqueued' do expect(job).to be_present end - - it 'fails' do - expect { job.perform }.to raise_error(SocketError) - end end end end diff --git a/spec/controllers/work_packages_controller_spec.rb b/spec/controllers/work_packages_controller_spec.rb index f6419183498..c41c30920f6 100644 --- a/spec/controllers/work_packages_controller_spec.rb +++ b/spec/controllers/work_packages_controller_spec.rb @@ -938,7 +938,7 @@ describe WorkPackagesController, type: :controller do describe '#journal' do let(:attachment_id) { "attachments_#{new_work_package.attachments.first.id}" } - subject { new_work_package.journals.last.changed_data } + subject { new_work_package.journals.last.details } it { is_expected.to have_key attachment_id } diff --git a/spec/exemplars/repository_exemplar.rb b/spec/exemplars/repository_exemplar.rb index c26d83043a4..23bdc404ab8 100644 --- a/spec/exemplars/repository_exemplar.rb +++ b/spec/exemplars/repository_exemplar.rb @@ -30,6 +30,7 @@ class Repository < ActiveRecord::Base generator_for type: 'Repository::Subversion' generator_for :url, method: :next_url + generator_for :scm_type, 'local' def self.next_url @last_url ||= 'file:///test/svn' diff --git a/spec/factories/project_factory.rb b/spec/factories/project_factory.rb index ac97a3fdc6b..68e80b911b0 100644 --- a/spec/factories/project_factory.rb +++ b/spec/factories/project_factory.rb @@ -72,123 +72,3 @@ FactoryGirl.define do end end end - -FactoryGirl.define do - factory(:timelines_project, class: Project) do - sequence(:name) do |n| "Project #{n}" end - sequence(:identifier) do |n| "project#{n}" end - - # activate timeline module - - callback(:after_create) do |project| - project.enabled_module_names += ['timelines'] - end - - # add user to project - - callback(:after_create) do |project| - role = FactoryGirl.create(:role) - member = FactoryGirl.build(:member, - # we could also just make everybody a member, - # since for now we can't pass transient - # attributes into factory_girl - user: project.responsible, - project: project) - member.roles = [role] - member.save! - end - - # generate planning elements - - callback(:after_create) do |project| - start_date = rand(18.months).ago - due_date = start_date - - (5 + rand(20)).times do - due_date = start_date + (rand(30) + 10).days - FactoryGirl.create(:planning_element, project: project, - start_date: start_date, - due_date: due_date) - start_date = due_date - end - end - - # create a timeline in that project - - callback(:after_create) do |project| - FactoryGirl.create(:timeline, project: project) - end - end -end - -FactoryGirl.define do - factory(:uerm_project, parent: :project) do - sequence(:name) do |n| "ÜRM Project #{n}" end - - @project_types = Array.new - @planning_element_types = Array.new - @colors = PlanningElementTypeColor.colors - - # create some project types - - callback(:after_create) do |_project| - if @project_types.empty? - - 6.times do - @project_types << FactoryGirl.create(:project_type) - end - - end - end - - # create some planning_element_types - - callback(:after_create) do |_project| - 20.times do - planning_element_type = FactoryGirl.create(:planning_element_type) - planning_element_type.color = @colors.sample - planning_element_type.save - - @planning_element_types << planning_element_type - end - end - - callback(:after_create) do |project| - projects = Array.new - - # create some projects - # - 50.times do - projects << FactoryGirl.create(:project, - responsible: project.responsible) - end - - projects << FactoryGirl.create(:project, - responsible: project.responsible) - - projects.each do |r| - # give every project a project type - - r.project_type = @project_types.sample - r.save - - # create a reporting to ürm - - FactoryGirl.create(:reporting, - project: r, - reporting_to_project: project) - - # give every planning element a planning element type - - r.planning_elements.each do |pe| - pe.planning_element_type = @planning_element_types.sample - pe.save! - end - - # Add a timeline with history - - FactoryGirl.create(:timeline_with_history, project: r) - end - end - end -end diff --git a/spec/factories/repository_factory.rb b/spec/factories/repository_factory.rb index bb6e7898846..c93403afdcf 100644 --- a/spec/factories/repository_factory.rb +++ b/spec/factories/repository_factory.rb @@ -27,9 +27,16 @@ #++ FactoryGirl.define do - factory :repository, class: Repository::Filesystem do - # Setting.enabled_scm should include "Filesystem" to successfully save the created repository - url 'file:///tmp/test_repo' + factory :repository_subversion, class: Repository::Subversion do + url 'file://tmp/svn_test_repo' + scm_type 'existing' + project + end + + factory :repository_git, class: Repository::Git do + url 'file://tmp/git_test_repo' + scm_type 'local' + path_encoding 'UTF-8' project end end diff --git a/spec/features/accessibility/work_packages/calendar_toggable_fieldsets_spec.rb b/spec/features/accessibility/work_packages/calendar_toggable_fieldsets_spec.rb index 24195a06b0b..17d268cde8d 100644 --- a/spec/features/accessibility/work_packages/calendar_toggable_fieldsets_spec.rb +++ b/spec/features/accessibility/work_packages/calendar_toggable_fieldsets_spec.rb @@ -45,7 +45,7 @@ describe 'Work package calendar index', type: :feature do end describe 'Filter fieldset', js: true do - it_behaves_like 'toggable fieldset initially expanded' do + it_behaves_like 'toggable fieldset initially collapsed' do let(:fieldset_name) { 'Filters' } end end diff --git a/spec/features/auth/omniauth_spec.rb b/spec/features/auth/omniauth_spec.rb index 23598bd0e5a..47a69e34034 100644 --- a/spec/features/auth/omniauth_spec.rb +++ b/spec/features/auth/omniauth_spec.rb @@ -167,6 +167,7 @@ describe 'Omniauth authentication', type: :feature do before do allow(Setting).to receive(:self_registration?).and_return(true) allow(Setting).to receive(:self_registration).and_return('3') + allow(Setting).to receive(:available_languages).and_return([:en]) end it_behaves_like 'omniauth user registration' diff --git a/spec/features/boards/message_spec.rb b/spec/features/boards/message_spec.rb index a47be630b38..05aa3e330fa 100644 --- a/spec/features/boards/message_spec.rb +++ b/spec/features/boards/message_spec.rb @@ -47,9 +47,7 @@ describe 'messages', type: :feature do end before do - visit project_path(topic.board.project) - click_on 'Forums' - click_on topic.subject, match: :first + visit topic_path(topic) end describe 'clicking on quote', js: true do diff --git a/spec/features/repositories/create_repository_spec.rb b/spec/features/repositories/create_repository_spec.rb new file mode 100644 index 00000000000..f97dc701633 --- /dev/null +++ b/spec/features/repositories/create_repository_spec.rb @@ -0,0 +1,205 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' +require 'features/repositories/repository_settings_page' + +describe 'Create repository', type: :feature, js: true do + let(:current_user) { FactoryGirl.create (:admin) } + let(:project) { FactoryGirl.create(:project) } + let(:settings_page) { RepositorySettingsPage.new(project) } + + # Allow to override configuration values to determine + # whether to activate managed repositories + let(:enabled_scms) { %w[Subversion Git] } + let(:config) { nil } + + let(:scm_vendor_input_css) { 'select[name="scm_vendor"]' } + let(:scm_vendor_input) { find(scm_vendor_input_css) } + + before do + allow(User).to receive(:current).and_return current_user + allow(Setting).to receive(:enabled_scm).and_return(enabled_scms) + + allow(OpenProject::Configuration).to receive(:[]).and_call_original + allow(OpenProject::Configuration).to receive(:[]).with('scm').and_return(config) + end + + describe 'vendor select' do + before do + settings_page.visit_repository_settings + end + shared_examples 'shows enabled scms' do + it 'displays the vendor selection' do + expect(scm_vendor_input).not_to be_nil + enabled_scms.each do |scm| + expect(scm_vendor_input).to have_selector('option', text: scm) + end + end + end + + context 'with the default enabled scms' do + it_behaves_like 'shows enabled scms' + end + + context 'with only one enabled scm' do + let(:enabled_scms) { %w[Subversion] } + it_behaves_like 'shows enabled scms' + it 'does not show git' do + expect(scm_vendor_input).not_to have_selector('option', text: 'Git') + end + end + end + + describe 'with submitted vendor form' do + before do + settings_page.visit_repository_settings + find("option[value='#{vendor}']").select_option + end + + shared_examples 'displays only the type' do |type| + it 'should display one type, but expanded' do + # There seems to be an issue with how the + # select is accessed after the async form loading + # Thus we explitly find it here to allow some wait + # even though it is available in let + scm_vendor = find(scm_vendor_input_css) + expect(scm_vendor.value).to eq(vendor) + + page.assert_selector('input[name="scm_type"]', count: 1) + scm_type = find('input[name="scm_type"]') + + expect(scm_type.value).to eq(type) + expect(scm_type[:selected]).to be_truthy + expect(scm_type[:disabled]).to be_falsey + + content = find("#toggleable-attributes-group--content-#{type}") + expect(content).not_to be_nil + expect(content[:hidden]).to be_falsey + end + end + + shared_examples 'displays collapsed type' do |type| + let(:selector) { find("input[name='scm_type'][value='#{type}']") } + + it 'should display a collapsed type' do + expect(selector).not_to be_nil + expect(selector[:selected]).to be_falsey + expect(selector[:disabled]).to be_falsey + + content = find("#toggleable-attributes-group--content-#{type}", visible: false) + expect(content).not_to be_nil + expect(content[:hidden]).to be_truthy + end + end + + shared_examples 'has managed and other type' do |type| + it_behaves_like 'displays collapsed type', type + it_behaves_like 'displays collapsed type', 'managed' + + it 'can toggle between the two' do + find("input[name='scm_type'][value='#{type}']").set(true) + content = find("#toggleable-attributes-group--content-#{type}") + expect(content).not_to be_nil + expect(content[:hidden]).to be_falsey + + find('input[type="radio"][value="managed"]').set(true) + content = find('#toggleable-attributes-group--content-managed') + expect(content).not_to be_nil + expect(content[:hidden]).to be_falsey + end + end + + shared_examples 'it can create the managed repository' do + it 'can complete the form without any parameters' do + find('input[type="radio"][value="managed"]').set(true) + find('button[type="submit"]', text: I18n.t(:button_create)).click + + expect(find('div.flash.notice')).not_to be_nil + expect(page).to have_selector('input[name="scm_type"][value="managed"]:checked') + expect(page).to have_selector('a.icon-delete', text: I18n.t(:button_delete)) + end + end + + shared_examples 'it can create the repository of type with url' do |type, url| + it 'can complete the form without any parameters' do + find("input[type='radio'][value='#{type}']").set(true) + find('input[name="repository[url]"]').set(url) + + find('button[type="submit"]', text: I18n.t(:button_create)).click + + expect(page).to have_selector('div.flash.notice') + expect(page).to have_selector('button[type="submit"]', text: I18n.t(:button_save)) + expect(page).to have_selector('a.icon-delete', text: I18n.t(:button_delete)) + end + end + + context 'with Subversion selected' do + let(:vendor) { 'Subversion' } + + it_behaves_like 'displays only the type', 'existing' + + context 'and managed repositories' do + include_context 'with tmpdir' + let(:config) { + { Subversion: { manages: tmpdir } } + } + it_behaves_like 'has managed and other type', 'existing' + it_behaves_like 'it can create the managed repository' + it_behaves_like 'it can create the repository of type with url', + 'existing', + 'file:///tmp/svn/foo.svn' + end + end + + context 'with Git selected' do + let(:vendor) { 'Git' } + + it_behaves_like 'displays only the type', 'local' + context 'and managed repositories, but not ours' do + let(:config) { + { Subversion: { manages: '/tmp/whatever' } } + } + it_behaves_like 'displays only the type', 'local' + end + + context 'and managed repositories' do + include_context 'with tmpdir' + let(:config) { + { Git: { manages: tmpdir } } + } + + it_behaves_like 'has managed and other type', 'local' + it_behaves_like 'it can create the managed repository' + it_behaves_like 'it can create the repository of type with url', + 'local', + '/tmp/git/foo.git' + end + end + end +end diff --git a/spec/features/repositories/repository_settings_page.rb b/spec/features/repositories/repository_settings_page.rb new file mode 100644 index 00000000000..2133483af09 --- /dev/null +++ b/spec/features/repositories/repository_settings_page.rb @@ -0,0 +1,44 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +class RepositorySettingsPage + include Rails.application.routes.url_helpers + include Capybara::DSL + + def initialize(project) + @project = project + end + + def repository_settings_path + settings_project_path(id: @project.id, tab: 'repository') + end + + def visit_repository_settings + visit repository_settings_path + end +end diff --git a/spec/features/repositories/repository_settings_spec.rb b/spec/features/repositories/repository_settings_spec.rb new file mode 100644 index 00000000000..6de538dc2a6 --- /dev/null +++ b/spec/features/repositories/repository_settings_spec.rb @@ -0,0 +1,145 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' +require 'features/repositories/repository_settings_page' + +describe 'Repository Settings', type: :feature, js: true do + let(:current_user) { FactoryGirl.create (:admin) } + let(:project) { FactoryGirl.create(:project) } + let(:settings_page) { RepositorySettingsPage.new(project) } + + # Allow to override configuration values to determine + # whether to activate managed repositories + let(:enabled_scms) { %w[Subversion Git] } + let(:config) { nil } + + before do + allow(User).to receive(:current).and_return current_user + allow(Setting).to receive(:enabled_scm).and_return(enabled_scms) + + allow(OpenProject::Configuration).to receive(:[]).and_call_original + allow(OpenProject::Configuration).to receive(:[]).with('scm').and_return(config) + + allow(project).to receive(:repository).and_return(repository) + settings_page.visit_repository_settings + end + + shared_examples 'manages the repository' do |type| + it 'displays the repository' do + expect(page).not_to have_selector('select[name="scm_vendor"]') + expect(find("#toggleable-attributes-group--content-#{type}", visible: true)) + .not_to be_nil + end + + it 'deletes the repository' do + expect(Repository.exists?(repository)).to be true + find('a.icon-delete', text: I18n.t(:button_delete)).click + + # Confirm the notification warning + warning = (type == 'managed') ? '-warning.-severe' : '-warning' + expect(page).to have_selector(".notification-box.#{warning}") + find('a', text: I18n.t(:button_delete)).click + + vendor = find('select[name="scm_vendor"]') + expect(vendor).not_to be_nil + expect(vendor.value).to be_empty + + selected = vendor.find('option[selected]') + expect(selected.text).to eq('--- Please select ---') + expect(selected[:disabled]).to be_truthy + expect(selected[:selected]).to be_truthy + + # Project should have no repository + expect(Repository.exists?(repository)).to be false + end + end + + shared_examples 'manages the repository with' do |name, type| + let(:repository) { + FactoryGirl.create("repository_#{name.downcase}".to_sym, + scm_type: type, + project: project) + } + it_behaves_like 'manages the repository', type + end + + it_behaves_like 'manages the repository with', 'Subversion', 'existing' + it_behaves_like 'manages the repository with', 'Git', 'local' + + context 'managed repositories' do + include_context 'with tmpdir' + let(:config) { + { + Subversion: { manages: File.join(tmpdir, 'svn') }, + Git: { manages: File.join(tmpdir, 'git') } + } + } + + let(:repository) { + repo = Repository.build( + project, + managed_vendor, + # Need to pass AC params here manually to simulate a regular repository build + ActionController::Parameters.new({}), + :managed + ) + + repo.save! + repo + } + + context 'Subversion' do + let(:managed_vendor) { 'Subversion' } + it_behaves_like 'manages the repository', 'managed' + end + + context 'Git' do + let(:managed_vendor) { 'Git' } + it_behaves_like 'manages the repository', 'managed' + end + end + + describe 'update repositories' do + let(:repository) { + FactoryGirl.create(:repository_subversion, + scm_type: :existing, + project: project) + } + + it 'can set login and password' do + fill_in('repository[login]', with: 'foobar') + fill_in('repository_password', with: 'password') + + click_button(I18n.t(:button_save)) + expect(page).to have_selector('[name="repository[login]"][value="foobar"]') + expect(page).to have_selector('.flash', + text: I18n.t('repositories.update_settings_successful')) + end + end +end diff --git a/spec/features/work_packages/details/activity_comments_spec.rb b/spec/features/work_packages/details/activity_comments_spec.rb index 9ace797ba5b..e54b6478527 100644 --- a/spec/features/work_packages/details/activity_comments_spec.rb +++ b/spec/features/work_packages/details/activity_comments_spec.rb @@ -18,20 +18,26 @@ describe 'activity comments', js: true do row = page.find("#work-package-#{work_package.id}") row.double_click - expect(find('#add-comment-text')).to be_present + ng_wait end it 'should alert user if navigating with unsaved form' do - page.execute_script("jQuery('#add-comment-text').val('Foobar').trigger('change')") + fill_in I18n.t('js.label_add_comment_title'), with: 'Foobar' + visit root_path + page.driver.browser.switch_to.alert.accept + expect(current_path).to eq(root_path) end it 'should not alert if comment has been submitted' do - page.execute_script("jQuery('#add-comment-text').val('Foobar').trigger('change')") - page.execute_script("jQuery('#add-comment-text').siblings('button').trigger('click')") + fill_in I18n.t('js.label_add_comment_title'), with: 'Foobar' + + click_button I18n.t('js.label_add_comment') + visit root_path + expect(current_path).to eq(root_path) end end diff --git a/spec/features/work_packages/details/inplace_editor/description_editor_spec.rb b/spec/features/work_packages/details/inplace_editor/description_editor_spec.rb index 81dd67e7894..7c2d03e356d 100644 --- a/spec/features/work_packages/details/inplace_editor/description_editor_spec.rb +++ b/spec/features/work_packages/details/inplace_editor/description_editor_spec.rb @@ -25,12 +25,7 @@ describe 'description inplace editor', js: true do before do allow(User).to receive(:current).and_return(user) - work_packages_page.visit_index - - row = page.find("#work-package-#{work_package.id}") - row.double_click - - ng_wait + work_packages_page.visit_index(work_package) end context 'in read state' do diff --git a/spec/features/work_packages/details/inplace_editor/subject_editor_spec.rb b/spec/features/work_packages/details/inplace_editor/subject_editor_spec.rb index 0e879189806..fc91ea6a9e5 100644 --- a/spec/features/work_packages/details/inplace_editor/subject_editor_spec.rb +++ b/spec/features/work_packages/details/inplace_editor/subject_editor_spec.rb @@ -18,14 +18,7 @@ describe 'subject inplace editor', js: true do before do allow(User).to receive(:current).and_return(user) - work_packages_page.visit_index - - ensure_wp_table_loaded - - row = page.find("#work-package-#{work_package.id}") - row.double_click - - ng_wait + work_packages_page.visit_index(work_package) end context 'in read state' do diff --git a/spec/features/work_packages/details/inplace_editor/work_package_field.rb b/spec/features/work_packages/details/inplace_editor/work_package_field.rb index 7a0720561da..65c954ce89d 100644 --- a/spec/features/work_packages/details/inplace_editor/work_package_field.rb +++ b/spec/features/work_packages/details/inplace_editor/work_package_field.rb @@ -1,11 +1,13 @@ class WorkPackageField + include Capybara::DSL + include RSpec::Matchers + attr_reader :element def initialize(page, property_name) @property_name = property_name - # safeguard to ensure that the details pane has been opened - page.find('.work-packages--details-content') + ensure_page_loaded @element = page.find(field_selector) end @@ -68,4 +70,13 @@ class WorkPackageField def errors_element @element.find('.inplace-edit--errors') end + + def ensure_page_loaded + if Capybara.current_driver == Capybara.javascript_driver + extend ::Angular::DSL unless singleton_class.included_modules.include?(::Angular::DSL) + ng_wait + + expect(page).to have_selector('.work-packages--details-content') + end + end end diff --git a/spec/features/work_packages/new_work_package_spec.rb b/spec/features/work_packages/new_work_package_spec.rb index 7fc239d23b0..19c1ffe3168 100644 --- a/spec/features/work_packages/new_work_package_spec.rb +++ b/spec/features/work_packages/new_work_package_spec.rb @@ -37,7 +37,7 @@ describe 'New work package', type: :feature do before do allow(User).to receive(:current).and_return(user) end describe 'Datepicker', js: true do - shared_examples_for 'first week day set' do |locale: 'de'| + shared_examples_for 'first week day set' do |locale: :de| let(:datepicker_selector) { '#ui-datepicker-div table.ui-datepicker-calendar thead tr th:nth-of-type(2)' } before do @@ -48,13 +48,9 @@ describe 'New work package', type: :feature do # Fill in the date, as a simple click does not seem to trigger the datepicker here fill_in 'Start date', with: DateTime.now.strftime('%Y-%m-%d') - - expect(page).to have_selector(datepicker_selector) end - subject { page.find(datepicker_selector).text } - - it { expect(subject).to eql(day_acronym) } + it { expect(page).to have_selector(datepicker_selector, text: day_acronym) } end context 'Monday' do @@ -84,7 +80,7 @@ describe 'New work package', type: :feature do let(:day_acronym) { 'Mo' } end - it_behaves_like 'first week day set', locale: 'en' do + it_behaves_like 'first week day set', locale: :en do let(:day_of_week) { nil } let(:day_acronym) { 'Su' } end diff --git a/spec/features/work_packages/work_packages_page.rb b/spec/features/work_packages/work_packages_page.rb index c16306dcb66..7669b32ff4a 100644 --- a/spec/features/work_packages/work_packages_page.rb +++ b/spec/features/work_packages/work_packages_page.rb @@ -35,8 +35,8 @@ class WorkPackagesPage @project = project end - def visit_index - visit index_path + def visit_index(work_package = nil) + visit index_path(work_package) ensure_index_page_loaded end @@ -77,8 +77,10 @@ class WorkPackagesPage private - def index_path - @project ? project_work_packages_path(@project) : work_packages_path + def index_path(work_package = nil) + path = @project ? project_work_packages_path(@project) : work_packages_path + path += "/#{work_package.id}/overview" if work_package + path end def query_path(query) @@ -87,6 +89,9 @@ class WorkPackagesPage def ensure_index_page_loaded if Capybara.current_driver == Capybara.javascript_driver + extend ::Angular::DSL unless singleton_class.included_modules.include?(::Angular::DSL) + ng_wait + expect(page).to have_selector('.advanced-filters--filter', visible: false) end end diff --git a/spec/fixtures/repositories.yml b/spec/fixtures/repositories.yml index 3ee2f67e4e7..93dc91b4071 100644 --- a/spec/fixtures/repositories.yml +++ b/spec/fixtures/repositories.yml @@ -35,6 +35,7 @@ repositories_001: password: "" login: "" type: Repository::Subversion + scm_type: existing repositories_002: project_id: 2 url: svn://localhost/test @@ -43,3 +44,4 @@ repositories_002: password: "" login: "" type: Repository::Subversion + scm_type: existing diff --git a/spec/fixtures/repositories/filesystem_repository.tar.gz b/spec/fixtures/repositories/filesystem_repository.tar.gz deleted file mode 100644 index 075d8c72a81..00000000000 Binary files a/spec/fixtures/repositories/filesystem_repository.tar.gz and /dev/null differ diff --git a/spec/fixtures/repositories/git_repository.tar.gz b/spec/fixtures/repositories/git_repository.tar.gz index c0a1577fe22..2250dcaa5c6 100644 Binary files a/spec/fixtures/repositories/git_repository.tar.gz and b/spec/fixtures/repositories/git_repository.tar.gz differ diff --git a/spec/fixtures/repositories/subversion_repository.tar.gz b/spec/fixtures/repositories/subversion_repository.tar.gz new file mode 100644 index 00000000000..7695b80ffee Binary files /dev/null and b/spec/fixtures/repositories/subversion_repository.tar.gz differ diff --git a/spec/legacy/functional/admin_controller_spec.rb b/spec/legacy/functional/admin_controller_spec.rb index 495ffe0f638..79ee6b63eab 100644 --- a/spec/legacy/functional/admin_controller_spec.rb +++ b/spec/legacy/functional/admin_controller_spec.rb @@ -45,13 +45,6 @@ describe AdminController, type: :controller do attributes: { class: /nodata/ } end - it 'should index with no configuration data' do - delete_configuration_data - get :projects - assert_tag tag: 'div', - attributes: { class: /nodata/ } - end - it 'should projects' do get :projects assert_response :success @@ -71,15 +64,6 @@ describe AdminController, type: :controller do assert_equal 'OnlineStore', projects.first.name end - it 'should load default configuration data' do - Setting.available_languages = [:de] - delete_configuration_data - post :default_configuration, lang: 'de' - assert_response :redirect - assert_nil flash[:error] - assert Status.find_by(name: 'neu') - end - it 'should test email' do get :test_email assert_redirected_to '/settings/edit?tab=notifications' @@ -141,13 +125,4 @@ describe AdminController, type: :controller do menu.delete :test_admin_menu_plugin_extension end end - - private - - def delete_configuration_data - Role.delete_all('builtin = 0') - ::Type.delete_all(is_standard: false) - Status.delete_all - Enumeration.delete_all - end end diff --git a/spec/legacy/functional/repositories_controller_spec.rb b/spec/legacy/functional/repositories_controller_spec.rb index b6a6cee2a64..cfda31a3169 100644 --- a/spec/legacy/functional/repositories_controller_spec.rb +++ b/spec/legacy/functional/repositories_controller_spec.rb @@ -32,10 +32,28 @@ require 'repositories_controller' describe RepositoriesController, type: :controller do render_views + # We load legacy fixtures and repository + # but now have to override them with the temporary subversion + # repository, as the filesystem repository has been stripped. fixtures :all + before do + unless repository_configured?('subversion') + skip 'Subversion test repository NOT FOUND. Skipping functional tests !!!' + end + end + + let(:project) { Project.find(1) } + let(:repository) { + FactoryGirl.create(:repository_subversion, + url: self.class.subversion_repository_url, + project: project + ) + } + before do User.current = nil + allow(project).to receive(:repository).and_return repository end it 'should revisions' do @@ -56,18 +74,32 @@ describe RepositoriesController, type: :controller do get :revision, project_id: 1, rev: 1 assert_response :success assert_template 'revision' - assert_no_tag tag: 'ul', attributes: { id: 'toolbar-items' }, - descendant: { tag: 'a', attributes: { href: @controller.url_for(only_path: true, - controller: 'repositories', - action: 'revision', - project_id: 'ecookbook', - rev: '0') } } - assert_tag tag: 'ul', attributes: { id: 'toolbar-items' }, - descendant: { tag: 'a', attributes: { href: @controller.url_for(only_path: true, - controller: 'repositories', - action: 'revision', - project_id: 'ecookbook', - rev: '2') } } + assert_no_tag tag: 'ul', + attributes: { id: 'toolbar-items' }, + descendant: { tag: 'a', + attributes: { + href: @controller.url_for( + only_path: true, + controller: 'repositories', + action: 'revision', + project_id: 'ecookbook', + rev: '0' + ) + } + } + assert_tag tag: 'ul', + attributes: { id: 'toolbar-items' }, + descendant: { tag: 'a', + attributes: { + href: @controller.url_for( + only_path: true, + controller: 'repositories', + action: 'revision', + project_id: 'ecookbook', + rev: '2' + ) + } + } end it 'should graph commits per month' do @@ -81,7 +113,7 @@ describe RepositoriesController, type: :controller do # add a commit with an unknown user Changeset.create!( repository: Project.find(1).repository, - committer: 'foo', + committer: 'foo', committed_on: Time.now, revision: 100, comments: 'Committed by foo.' @@ -91,17 +123,20 @@ describe RepositoriesController, type: :controller do assert_response :success assert_template 'committers' - assert_tag :td, content: 'dlopper', - sibling: { tag: 'td', - child: { tag: 'select', attributes: { name: %r{^committers\[\d+\]\[\]$} }, - child: { tag: 'option', content: 'Dave Lopper', - attributes: { value: '3', selected: 'selected' } } } } - assert_tag :td, content: 'foo', - sibling: { tag: 'td', - child: { tag: 'select', attributes: { name: %r{^committers\[\d+\]\[\]$} } } } - assert_no_tag :td, content: 'foo', - sibling: { tag: 'td', - descendant: { tag: 'option', attributes: { selected: 'selected' } } } + assert_tag :td, + content: 'foo', + sibling: { + tag: 'td', + child: { tag: 'select', + attributes: { name: %r{^committers\[\d+\]\[\]$} } + } + } + assert_no_tag :td, + content: 'foo', + sibling: { + tag: 'td', + descendant: { tag: 'option', attributes: { selected: 'selected' } } + } end it 'should map committers' do @@ -109,13 +144,14 @@ describe RepositoriesController, type: :controller do # add a commit with an unknown user c = Changeset.create!( repository: Project.find(1).repository, - committer: 'foo', + committer: 'foo', committed_on: Time.now, revision: 100, comments: 'Committed by foo.' ) - assert_no_difference 'Changeset.where(user_id: 3).count' do - post :committers, project_id: 1, committers: { '0' => ['foo', '2'], '1' => ['dlopper', '3'] } + assert_no_difference "Changeset.count(:conditions => 'user_id = 3')" do + post :committers, project_id: 1, + committers: { '0' => ['foo', '2'], '1' => ['dlopper', '3'] } assert_redirected_to '/projects/ecookbook/repository/committers' assert_equal User.find(2), c.reload.user end diff --git a/spec/legacy/functional/repositories_filesystem_controller_spec.rb b/spec/legacy/functional/repositories_filesystem_controller_spec.rb deleted file mode 100644 index 48b150774c3..00000000000 --- a/spec/legacy/functional/repositories_filesystem_controller_spec.rb +++ /dev/null @@ -1,127 +0,0 @@ -#-- encoding: UTF-8 -#-- copyright -# OpenProject is a project management system. -# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See doc/COPYRIGHT.rdoc for more details. -#++ -require 'legacy_spec_helper' -require 'repositories_controller' - -describe RepositoriesController, 'Filesystem', type: :controller do - render_views - - fixtures :all - - PRJ_ID = 3 - - before do - session[:user_id] = 1 # admin - - with_existing_filesystem_scm do |repo_path| - @repository = Repository::Filesystem.create(project: Project.find(PRJ_ID), - url: repo_path, - path_encoding: nil) - assert @repository - end - end - - after do - User.current = nil - end - - it 'should browse root' do - with_existing_filesystem_scm do - @repository.fetch_changesets - @repository.reload - get :show, project_id: PRJ_ID - assert_response :success - assert_template 'show' - assert_not_nil assigns(:entries) - assert assigns(:entries).size > 0 - assert_not_nil assigns(:changesets) - assert assigns(:changesets).size == 0 - end - end - - it 'should show no extension' do - with_existing_filesystem_scm do - get :entry, project_id: PRJ_ID, path: 'test' - assert_response :success - assert_template 'entry' - assert_tag tag: 'th', - content: '1', - attributes: { class: 'line-num' }, - sibling: { tag: 'td', content: /TEST CAT/ } - end - end - - it 'should entry download no extension' do - with_existing_filesystem_scm do |_| - get :entry, project_id: PRJ_ID, path: 'test', format: 'raw' - assert_response :success - assert_equal 'application/octet-stream', response.content_type - end - end - - it 'should show non ascii contents' do - with_existing_filesystem_scm do - with_settings repositories_encodings: 'UTF-8,EUC-JP' do - get :entry, project_id: PRJ_ID, path: 'japanese/euc-jp.txt' - assert_response :success - assert_template 'entry' - assert_tag tag: 'th', - content: '2', - attributes: { class: 'line-num' }, - sibling: { tag: 'td', content: /japanese/ } - end - end - end - - it 'should show utf16' do - with_existing_filesystem_scm do - with_settings repositories_encodings: 'UTF-16' do - get :entry, project_id: PRJ_ID, path: 'japanese/utf-16.txt' - assert_response :success - - assert_select 'tr' do - assert_select 'th.line-num' do - assert_select 'a', text: /2/ - end - assert_select 'td', content: /japanese/ - end - end - end - end - - it 'should show text file should send if too big' do - with_existing_filesystem_scm do - with_settings file_max_size_displayed: 1 do - get :entry, project_id: PRJ_ID, path: 'japanese/big-file.txt' - assert_response :success - assert_equal 'text/plain', response.content_type - end - end - end -end diff --git a/spec/legacy/functional/repositories_git_controller_spec.rb b/spec/legacy/functional/repositories_git_controller_spec.rb index dc13f80b744..689862f7e75 100644 --- a/spec/legacy/functional/repositories_git_controller_spec.rb +++ b/spec/legacy/functional/repositories_git_controller_spec.rb @@ -47,6 +47,7 @@ describe RepositoriesController, 'Git', type: :controller do User.current = nil @repository = Repository::Git.create( project: Project.find(3), + scm_type: 'local', url: git_repository_path, path_encoding: 'ISO-8859-1' ) @@ -67,7 +68,7 @@ describe RepositoriesController, 'Git', type: :controller do assert_response :success assert_template 'show' assert_not_nil assigns(:entries) - assert_equal 9, assigns(:entries).size + assert_equal 10, assigns(:entries).size assert assigns(:entries).detect { |e| e.name == 'images' && e.kind == 'dir' } assert assigns(:entries).detect { |e| e.name == 'this_is_a_really_long_and_verbose_directory_name' && e.kind == 'dir' } assert assigns(:entries).detect { |e| e.name == 'sources' && e.kind == 'dir' } @@ -146,7 +147,7 @@ describe RepositoriesController, 'Git', type: :controller do get :changes, project_id: 3, path: 'images/edit.png' assert_response :success assert_template 'changes' - assert_tag tag: 'h2', content: 'edit.png' + assert_tag tag: 'h3', content: 'edit.png' end it 'should entry show' do @@ -223,7 +224,7 @@ describe RepositoriesController, 'Git', type: :controller do get :annotate, project_id: 3, rev: 'deff7', path: 'sources/watchers_controller.rb' assert_response :success assert_template 'annotate' - assert_tag tag: 'h2', content: /@ deff712f/ + assert_tag tag: 'h3', content: /@ deff712f/ end it 'should annotate binary file' do diff --git a/spec/legacy/functional/repositories_subversion_controller_spec.rb b/spec/legacy/functional/repositories_subversion_controller_spec.rb index 8db535f4dc4..4223654aa2e 100644 --- a/spec/legacy/functional/repositories_subversion_controller_spec.rb +++ b/spec/legacy/functional/repositories_subversion_controller_spec.rb @@ -44,6 +44,7 @@ describe RepositoriesController, 'Subversion', type: :controller do @project = Project.find(PRJ_ID) @repository = Repository::Subversion.create(project: @project, + scm_type: 'local', url: self.class.subversion_repository_url) # #reload is broken for repositories because it defines @@ -114,7 +115,7 @@ describe RepositoriesController, 'Subversion', type: :controller do assert_equal %w(6 3 2), changesets.map(&:revision) # svn properties displayed with svn >= 1.5 only - if Redmine::Scm::Adapters::SubversionAdapter.client_version_above?([1, 5, 0]) + if @repository.scm.client_version_above?([1, 5, 0]) assert_not_nil assigns(:properties) assert_equal 'native', assigns(:properties)['svn:eol-style'] assert_tag :ul, @@ -243,7 +244,7 @@ describe RepositoriesController, 'Subversion', type: :controller do it 'should revision with repository pointing to a subdirectory' do r = Project.find(1).repository # Changes repository url to a subdirectory - r.update_attribute :url, (r.url + '/test/some') + r.update_attribute :url, (r.url + '/subversion_test/folder/') get :revision, project_id: 1, rev: 2 assert_response :success @@ -252,11 +253,11 @@ describe RepositoriesController, 'Subversion', type: :controller do child: { tag: 'li', # link to the entry at rev 2 child: { tag: 'a', - attributes: { href: '/projects/ecookbook/repository/revisions/2/entry/path/in/the/repo' }, + attributes: { href: '/projects/ecookbook/repository/revisions/2/entry/test/some/path/in/the/repo' }, content: 'repo', # link to partial diff sibling: { tag: 'a', - attributes: { href: '/projects/ecookbook/repository/revisions/2/diff/path/in/the/repo' } + attributes: { href: '/projects/ecookbook/repository/revisions/2/diff/test/some/path/in/the/repo' } } } } @@ -301,6 +302,6 @@ describe RepositoriesController, 'Subversion', type: :controller do get :annotate, project_id: PRJ_ID, rev: 8, path: 'subversion_test/helloworld.c' assert_response :success assert_template 'annotate' - assert_tag tag: 'h2', content: /@ 8/ + assert_tag tag: 'h3', content: /@ 8/ end end diff --git a/spec/legacy/functional/sys_controller_spec.rb b/spec/legacy/functional/sys_controller_spec.rb index a927198180b..be4f572ec9b 100644 --- a/spec/legacy/functional/sys_controller_spec.rb +++ b/spec/legacy/functional/sys_controller_spec.rb @@ -51,7 +51,8 @@ describe SysController, type: :controller do assert_nil Project.find(4).repository post :create_project_repository, id: 4, - vendor: 'Subversion', + scm_vendor: 'Subversion', + scm_type: 'existing', repository: { url: 'file:///create/project/repository/subproject2' } assert_response :created diff --git a/spec/legacy/functional/user_mailer_spec.rb b/spec/legacy/functional/user_mailer_spec.rb index 48c25cf8267..da90c82b152 100644 --- a/spec/legacy/functional/user_mailer_spec.rb +++ b/spec/legacy/functional/user_mailer_spec.rb @@ -201,7 +201,7 @@ describe UserMailer, type: :mailer do it 'should email headers' do user = FactoryGirl.create(:user) issue = FactoryGirl.create(:work_package) - mail = UserMailer.work_package_added(user, issue, user) + mail = UserMailer.work_package_added(user, issue.journals.first, user) assert mail.deliver assert_not_nil mail assert_equal 'bulk', mail.header['Precedence'].to_s @@ -212,7 +212,7 @@ describe UserMailer, type: :mailer do Setting.plain_text_mail = 1 user = FactoryGirl.create(:user) issue = FactoryGirl.create(:work_package) - UserMailer.work_package_added(user, issue, user).deliver + UserMailer.work_package_added(user, issue.journals.first, user).deliver mail = ActionMailer::Base.deliveries.last assert_match /text\/plain/, mail.content_type assert_equal 0, mail.parts.size @@ -223,7 +223,7 @@ describe UserMailer, type: :mailer do Setting.plain_text_mail = 0 user = FactoryGirl.create(:user) issue = FactoryGirl.create(:work_package) - UserMailer.work_package_added(user, issue, user).deliver + UserMailer.work_package_added(user, issue.journals.first, user).deliver mail = ActionMailer::Base.deliveries.last assert_match /multipart\/alternative/, mail.content_type assert_equal 2, mail.parts.size @@ -264,7 +264,7 @@ describe UserMailer, type: :mailer do it 'should issue add message id' do user = FactoryGirl.create(:user) issue = FactoryGirl.create(:work_package) - mail = UserMailer.work_package_added(user, issue, user) + mail = UserMailer.work_package_added(user, issue.journals.first, user) mail.deliver assert_not_nil mail assert_equal UserMailer.generate_message_id(issue, user), mail.message_id @@ -318,7 +318,7 @@ describe UserMailer, type: :mailer do ActionMailer::Base.deliveries.clear with_settings available_languages: ['en', 'de'] do I18n.locale = 'en' - assert UserMailer.work_package_added(user, issue, user).deliver + assert UserMailer.work_package_added(user, issue.journals.first, user).deliver assert_equal 1, ActionMailer::Base.deliveries.size mail = last_email assert_equal ['foo@bar.de'], mail.to @@ -338,7 +338,7 @@ describe UserMailer, type: :mailer do with_settings available_languages: ['en', 'de'], default_language: 'de' do I18n.locale = 'de' - assert UserMailer.work_package_added(user, issue, user).deliver + assert UserMailer.work_package_added(user, issue.journals.first, user).deliver assert_equal 1, ActionMailer::Base.deliveries.size mail = last_email assert_equal ['foo@bar.de'], mail.to @@ -490,17 +490,12 @@ describe UserMailer, type: :mailer do # now change the issue, to get a nice journal issue.description = "This is related to issue ##{related_issue.id}\n" - changeset = with_existing_filesystem_scm { |repo_url| - repository = FactoryGirl.build(:repository, - url: repo_url, + repository = FactoryGirl.create(:repository_subversion, project: project) - repository.save! - - FactoryGirl.create :changeset, + changeset = FactoryGirl.create :changeset, repository: repository, comments: 'This commit fixes #1, #2 and references #1 and #3' - } issue.description += " A reference to a changeset r#{changeset.revision}\n" if changeset diff --git a/spec/legacy/support/legacy_assertions.rb b/spec/legacy/support/legacy_assertions.rb index 16db4d3fe38..f83928a8be6 100644 --- a/spec/legacy/support/legacy_assertions.rb +++ b/spec/legacy/support/legacy_assertions.rb @@ -116,20 +116,6 @@ module LegacyAssertionsAndHelpers saved_settings.each { |k, v| Setting[k] = v } end - REPOSITORY_PATH = Rails.root.to_s.gsub(%r{config\/\.\.}, '') + '/tmp/test/filesystem_repository' - - def with_existing_filesystem_scm(&block) - if Dir.exists?(REPOSITORY_PATH) - Setting.enabled_scm = Setting.enabled_scm << 'Filesystem' unless Setting.enabled_scm.include? 'Filesystem' - OpenProject::Configuration['scm_filesystem_path_whitelist'] = [REPOSITORY_PATH] - - block.call(REPOSITORY_PATH) - else - warn 'Filesystem test repository NOT FOUND. Skipping tests !!! See doc/RUNNING_TESTS.' - nil - end - end - def change_user_password(login, new_password) user = User.find_by_login(login) user.password = new_password diff --git a/spec/legacy/unit/changeset_spec.rb b/spec/legacy/unit/changeset_spec.rb index 3d99225698b..1eb7317f68f 100644 --- a/spec/legacy/unit/changeset_spec.rb +++ b/spec/legacy/unit/changeset_spec.rb @@ -187,7 +187,8 @@ describe Changeset, type: :model do # repository of child project r = Repository::Subversion.create!( project: Project.find(3), - url: 'svn://localhost/test') + scm_type: 'existing', + url: 'svn://localhost/test') c = Changeset.new(repository: r, committed_on: Time.now, @@ -235,128 +236,31 @@ describe Changeset, type: :model do assert_nil changeset.next end - it 'should comments should be converted to utf8' do - with_settings enabled_scm: ['Filesystem'] do - with_existing_filesystem_scm do |repo_url| - proj = Project.find(3) - str = File.read(Rails.root.join('spec/fixtures/encoding/iso-8859-1.txt')) - r = Repository::Filesystem.create!(project: proj, - url: repo_url, - log_encoding: 'ISO-8859-1') - assert r - c = Changeset.new(repository: r, - committed_on: Time.now, - revision: '123', - scmid: '12345', - comments: str) - assert(c.save) - assert_equal 'Texte encodé en ISO-8859-1.', c.comments - end - end - end - - it 'should invalid utf8 sequences in comments should be replaced latin1' do - with_settings enabled_scm: ['Filesystem'] do - with_existing_filesystem_scm do |repo_url| - proj = Project.find(3) - str = File.read(Rails.root.join('spec/fixtures/encoding/iso-8859-1.txt')) - r = Repository::Filesystem.create!(project: proj, - url: repo_url, - log_encoding: 'UTF-8') - assert r - c = Changeset.new(repository: r, - committed_on: Time.now, - revision: '123', - scmid: '12345', - comments: str) - assert(c.save) - assert_equal 'Texte encod? en ISO-8859-1.', c.comments - end - end - end - - it 'should invalid utf8 sequences in comments should be replaced ja jis' do - with_settings enabled_scm: ['Filesystem'] do - with_existing_filesystem_scm do |repo_url| - proj = Project.find(3) - str = "test\xb5\xfetest\xb5\xfe" - if str.respond_to?(:force_encoding) - str.force_encoding('ASCII-8BIT') - end - r = Repository::Filesystem.create!(project: proj, - url: repo_url, - log_encoding: 'ISO-2022-JP') - assert r - c = Changeset.new(repository: r, - committed_on: Time.now, - revision: '123', - scmid: '12345', - comments: str) - assert(c.save) - assert_equal 'test??test??', c.comments - end - end - end - - it 'should comments should be converted all latin1 to utf8' do - with_settings enabled_scm: ['Filesystem'] do - with_existing_filesystem_scm do |repo_url| - s1 = "\xC2\x80" - s2 = "\xc3\x82\xc2\x80" - s4 = s2.dup - if s1.respond_to?(:force_encoding) - s3 = s1.dup - s1.force_encoding('ASCII-8BIT') - s2.force_encoding('ASCII-8BIT') - s3.force_encoding('ISO-8859-1') - s4.force_encoding('UTF-8') - assert_equal s3.encode('UTF-8'), s4 - end - proj = Project.find(3) - r = Repository::Filesystem.create!(project: proj, - url: repo_url, - log_encoding: 'ISO-8859-1') - assert r - c = Changeset.new(repository: r, - committed_on: Time.now, - revision: '123', - scmid: '12345', - comments: s1) - assert(c.save) - assert_equal s4, c.comments - end - end - end - it 'should comments nil' do - with_settings enabled_scm: ['Filesystem'] do - with_existing_filesystem_scm do |repo_url| - proj = Project.find(3) - r = Repository::Filesystem.create!(project: proj, - url: repo_url, - log_encoding: 'ISO-8859-1') - assert r - c = Changeset.new(repository: r, - committed_on: Time.now, - revision: '123', - scmid: '12345', - comments: nil) - assert(c.save) - assert_equal '', c.comments - if c.comments.respond_to?(:force_encoding) - assert_equal 'UTF-8', c.comments.encoding.to_s - end + with_settings enabled_scm: ['Subversion'] do + proj = Project.find(3) + r = FactoryGirl.create(:repository_subversion, + project: proj) + assert r + + c = Changeset.new(repository: r, + committed_on: Time.now, + revision: '123', + scmid: '12345', + comments: nil) + assert(c.save) + assert_equal '', c.comments + if c.comments.respond_to?(:force_encoding) + assert_equal 'UTF-8', c.comments.encoding.to_s end end end it 'should comments empty' do - with_settings enabled_scm: ['Filesystem'] do - with_existing_filesystem_scm do |repo_url| + with_settings enabled_scm: ['Subversion'] do proj = Project.find(3) - r = Repository::Filesystem.create!(project: proj, - url: repo_url, - log_encoding: 'ISO-8859-1') + r = FactoryGirl.create(:repository_subversion) + assert r c = Changeset.new(repository: r, committed_on: Time.now, @@ -370,7 +274,6 @@ describe Changeset, type: :model do end end end - end it 'should identifier' do c = Changeset.find_by(revision: '1') diff --git a/spec/legacy/unit/comment_spec.rb b/spec/legacy/unit/comment_spec.rb index 9a2929abe32..2bc7248c7c7 100644 --- a/spec/legacy/unit/comment_spec.rb +++ b/spec/legacy/unit/comment_spec.rb @@ -81,13 +81,13 @@ describe Comment, type: :model do news = FactoryGirl.create(:news, project: project, author: user) # with notifications for that event turned on - allow(Notifier).to receive(:notify?).with(:news_comment_added).and_return(true) + allow(Setting).to receive(:notified_events).and_return(['news_comment_added']) assert_difference 'ActionMailer::Base.deliveries.size', 1 do Comment.create!(commented: news, author: user, comments: 'more useful stuff') end # with notifications for that event turned off - allow(Notifier).to receive(:notify?).with(:news_comment_added).and_return(false) + allow(Setting).to receive(:notified_events).and_return([]) assert_no_difference 'ActionMailer::Base.deliveries.size' do Comment.create!(commented: news, author: user, comments: 'more useful stuff') end diff --git a/spec/legacy/unit/helpers/application_helper_spec.rb b/spec/legacy/unit/helpers/application_helper_spec.rb index 6d6905e1961..ae5bb6f34a8 100644 --- a/spec/legacy/unit/helpers/application_helper_spec.rb +++ b/spec/legacy/unit/helpers/application_helper_spec.rb @@ -194,40 +194,38 @@ RAW 'invalid:version:"1.0"' => 'invalid:version:"1.0"' } - with_existing_filesystem_scm do |repo_path| - repository = FactoryGirl.create :repository, - url: repo_path, - project: @project - changeset = FactoryGirl.create :changeset, - repository: repository, - comments: 'This commit fixes #1, #2 and references #1 & #3' - identifier = @project.identifier + repository = FactoryGirl.create :repository_subversion, + project: @project - source_link = link_to("#{identifier}:source:/some/file", - { controller: 'repositories', - action: 'entry', - project_id: identifier, - path: 'some/file' }, - class: 'source') - changeset_link = link_to("#{identifier}:r#{changeset.revision}", - { controller: 'repositories', - action: 'revision', - project_id: identifier, - rev: changeset.revision }, - class: 'changeset', - title: 'This commit fixes #1, #2 and references #1 & #3') + changeset = FactoryGirl.create :changeset, + repository: repository, + comments: 'This commit fixes #1, #2 and references #1 & #3' + identifier = @project.identifier - to_test.merge!( - # changeset - "r#{changeset.revision}" => "r#{changeset.revision}", - "#{@project.identifier}:r#{changeset.revision}" => changeset_link, - "invalid:r#{changeset.revision}" => "invalid:r#{changeset.revision}", - # source - 'source:/some/file' => 'source:/some/file', - "#{@project.identifier}:source:/some/file" => source_link, - 'invalid:source:/some/file' => 'invalid:source:/some/file', - ) - end + source_link = link_to("#{identifier}:source:/some/file", + { controller: 'repositories', + action: 'entry', + project_id: identifier, + path: 'some/file' }, + class: 'source') + changeset_link = link_to("#{identifier}:r#{changeset.revision}", + { controller: 'repositories', + action: 'revision', + project_id: identifier, + rev: changeset.revision }, + class: 'changeset', + title: 'This commit fixes #1, #2 and references #1 & #3') + + to_test.merge!( + # changeset + "r#{changeset.revision}" => "r#{changeset.revision}", + "#{@project.identifier}:r#{changeset.revision}" => changeset_link, + "invalid:r#{changeset.revision}" => "invalid:r#{changeset.revision}", + # source + 'source:/some/file' => 'source:/some/file', + "#{@project.identifier}:source:/some/file" => source_link, + 'invalid:source:/some/file' => 'invalid:source:/some/file', + ) # helper.format_text "sees" the text is parses from the_other_project (and not @project) the_other_project = FactoryGirl.create :valid_project @@ -251,7 +249,7 @@ RAW to_test = { 'commit:abcd' => changeset_link, } - r = Repository::Git.create!(project: @project, url: '/tmp/test/git') + r = Repository::Git.create!(project: @project, scm_type: 'local', url: '/tmp/test/git') assert r c = Changeset.new(repository: r, committed_on: Time.now, diff --git a/spec/legacy/unit/issue_nested_set_spec.rb b/spec/legacy/unit/issue_nested_set_spec.rb index f9d5b06e3b4..133130a3391 100644 --- a/spec/legacy/unit/issue_nested_set_spec.rb +++ b/spec/legacy/unit/issue_nested_set_spec.rb @@ -77,7 +77,8 @@ describe 'IssueNestedSet', type: :model do assert_equal [1, parent1.id, 5], [parent1.project_id, parent1.root_id, parent1.nested_set_span] # child can not be moved to Project 2 because its child is on a disabled type - assert_equal false, WorkPackage.find(child.id).move_to_project(Project.find(2)) + service = MoveWorkPackageService.new(child, User.current) + assert_equal false, service.call(Project.find(2)) child.reload grandchild.reload parent1.reload diff --git a/spec/legacy/unit/journal_observer_spec.rb b/spec/legacy/unit/journal_observer_spec.rb deleted file mode 100644 index d6f78a2c37f..00000000000 --- a/spec/legacy/unit/journal_observer_spec.rb +++ /dev/null @@ -1,133 +0,0 @@ -#-- encoding: UTF-8 -#-- copyright -# OpenProject is a project management system. -# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See doc/COPYRIGHT.rdoc for more details. -#++ -require 'legacy_spec_helper' - -describe JournalObserver, type: :model do - before do - @type = FactoryGirl.create :type_with_workflow - @project = FactoryGirl.create :project, - types: [@type] - @workflow = @type.workflows.first - @user = FactoryGirl.create :user, - mail_notification: 'all', - member_in_project: @project - @issue = FactoryGirl.create :work_package, - project: @project, - author: @user, - type: @type, - status: @workflow.old_status - - @user.members.first.roles << @workflow.role - @user.reload - - allow(User).to receive(:current).and_return(@user) - - ActionMailer::Base.deliveries.clear - end - - context "#after_create for 'work_package_updated'" do - it 'should send a notification when configured as a notification' do - Setting.notified_events = ['work_package_updated'] - assert_difference('ActionMailer::Base.deliveries.size', +1) do - @issue.add_journal(@user) - @issue.subject = 'A change to the issue' - assert @issue.save(validate: false) - end - end - - it 'should not send a notification with not configured' do - Setting.notified_events = [] - assert_no_difference('ActionMailer::Base.deliveries.size') do - @issue.add_journal(@user) - @issue.subject = 'A change to the issue' - assert @issue.save(validate: false) - end - end - end - - context "#after_create for 'work_package_note_added'" do - it 'should send a notification when configured as a notification' do - @issue.recreate_initial_journal! - - Setting.notified_events = ['work_package_note_added'] - assert_difference('ActionMailer::Base.deliveries.size', +1) do - @issue.add_journal(@user, 'This update has a note') - assert @issue.save(validate: false) - end - end - - it 'should not send a notification with not configured' do - Setting.notified_events = [] - assert_no_difference('ActionMailer::Base.deliveries.size') do - @issue.add_journal(@user, 'This update has a note') - assert @issue.save(validate: false) - end - end - end - - context "#after_create for 'status_updated'" do - it 'should send a notification when configured as a notification' do - Setting.notified_events = ['status_updated'] - assert_difference('ActionMailer::Base.deliveries.size', +1) do - @issue.add_journal(@user) - @issue.status = @workflow.new_status - assert @issue.save(validate: false) - end - end - - it 'should not send a notification with not configured' do - Setting.notified_events = [] - assert_no_difference('ActionMailer::Base.deliveries.size') do - @issue.add_journal(@user) - @issue.status = @workflow.new_status - assert @issue.save(validate: false) - end - end - end - - context "#after_create for 'work_package_priority_updated'" do - it 'should send a notification when configured as a notification' do - Setting.notified_events = ['work_package_priority_updated'] - assert_difference('ActionMailer::Base.deliveries.size', +1) do - @issue.add_journal(@user) - @issue.priority = IssuePriority.generate! - assert @issue.save(validate: false) - end - end - - it 'should not send a notification with not configured' do - Setting.notified_events = [] - assert_no_difference('ActionMailer::Base.deliveries.size') do - @issue.add_journal(@user) - @issue.priority = IssuePriority.generate! - assert @issue.save(validate: false) - end - end - end -end diff --git a/spec/legacy/unit/journal_spec.rb b/spec/legacy/unit/journal_spec.rb index 25efc49624c..260696ff8ad 100644 --- a/spec/legacy/unit/journal_spec.rb +++ b/spec/legacy/unit/journal_spec.rb @@ -37,26 +37,24 @@ describe Journal, type: :model do end end - it 'should create should send email notification' do - ActionMailer::Base.deliveries.clear - issue = WorkPackage.first + it 'create should send email notification' do + issue = WorkPackage.find(:first) if issue.journals.empty? issue.add_journal(User.current, 'This journal represents the creationa of journal version 1') issue.save end - user = User.first - assert_equal 0, ActionMailer::Base.deliveries.size + ActionMailer::Base.deliveries.clear issue.reload issue.update_attribute(:subject, 'New subject to trigger automatic journal entry') assert_equal 2, ActionMailer::Base.deliveries.size end - it 'should create should not send email notification if told not to' do + it 'create should not send email notification if told not to' do ActionMailer::Base.deliveries.clear issue = WorkPackage.first user = User.first journal = issue.add_journal(user, 'A note') - JournalObserver.instance.send_notification = false + JournalManager.send_notification = false assert_difference('Journal.count') do assert issue.save @@ -80,9 +78,9 @@ describe Journal, type: :model do end journal = issue.reload.journals.first - assert_equal [nil, 'Test initial journal'], journal.changed_data[:subject] - assert_equal [nil, @project.id], journal.changed_data[:project_id] - assert_equal [nil, 'Some content'], journal.changed_data[:description] + assert_equal [nil, 'Test initial journal'], journal.details[:subject] + assert_equal [nil, @project.id], journal.details[:project_id] + assert_equal [nil, 'Some content'], journal.details[:description] end specify 'creating a journal should update the updated_on value of the parent record (touch)' do diff --git a/spec/legacy/unit/lib/redmine/hook_spec.rb b/spec/legacy/unit/lib/redmine/hook_spec.rb index db0029e26d2..389d79ea0b2 100644 --- a/spec/legacy/unit/lib/redmine/hook_spec.rb +++ b/spec/legacy/unit/lib/redmine/hook_spec.rb @@ -156,17 +156,17 @@ describe 'Redmine::Hook::Manager' do # FIXME: naming (RSpec-port) it 'should call_hook_should_not_change_the_default_url_for_email_notifications' do user = User.find(1) - issue = WorkPackage.find(1) + issue = FactoryGirl.create(:work_package) ActionMailer::Base.deliveries.clear - UserMailer.work_package_added(user, issue, user).deliver + UserMailer.work_package_added(user, issue.journals.first, user).deliver mail = ActionMailer::Base.deliveries.last @hook_module.add_listener(TestLinkToHook) hook_helper.call_hook(:view_layouts_base_html_head) ActionMailer::Base.deliveries.clear - UserMailer.work_package_added(user, issue, user).deliver + UserMailer.work_package_added(user, issue.journals.first, user).deliver mail2 = ActionMailer::Base.deliveries.last assert_equal mail.text_part.body.encoded, mail2.text_part.body.encoded diff --git a/spec/legacy/unit/lib/redmine/scm/adapters/filesystem_adapter_spec.rb b/spec/legacy/unit/lib/redmine/scm/adapters/filesystem_adapter_spec.rb deleted file mode 100644 index 49fbb672499..00000000000 --- a/spec/legacy/unit/lib/redmine/scm/adapters/filesystem_adapter_spec.rb +++ /dev/null @@ -1,62 +0,0 @@ -#-- encoding: UTF-8 -#-- copyright -# OpenProject is a project management system. -# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See doc/COPYRIGHT.rdoc for more details. -#++ - -require 'legacy_spec_helper' - -describe Redmine::Scm::Adapters::FilesystemAdapter, type: :model do - let(:fs_repository_path) { Rails.root.to_s.gsub(%r{config\/\.\.}, '') + '/tmp/test/filesystem_repository' } - - before do - skip 'Filesystem test repository NOT FOUND. Skipping unit tests !!! See doc/RUNNING_TESTS.' unless File.directory?(fs_repository_path) - - @adapter = Redmine::Scm::Adapters::FilesystemAdapter.new(fs_repository_path) - end - - it 'should entries' do - assert_equal 3, @adapter.entries.size - assert_equal ['dir', 'japanese', 'test'], @adapter.entries.map(&:name) - assert_equal ['dir', 'japanese', 'test'], @adapter.entries(nil).map(&:name) - assert_equal ['dir', 'japanese', 'test'], @adapter.entries('/').map(&:name) - ['dir', '/dir', '/dir/', 'dir/'].each do |path| - assert_equal ['subdir', 'dirfile'], @adapter.entries(path).map(&:name) - end - # If y try to use "..", the path is ignored - ['/../', 'dir/../', '..', '../', '/..', 'dir/..'].each do |path| - assert_equal ['dir', 'japanese', 'test'], @adapter.entries(path).map(&:name), - '.. must be ignored in path argument' - end - end - - it 'should cat' do - assert_equal "TEST CAT\n", @adapter.cat('test') - assert_equal "TEST CAT\n", @adapter.cat('/test') - # Revision number is ignored - assert_equal "TEST CAT\n", @adapter.cat('/test', 1) - end -end diff --git a/spec/legacy/unit/lib/redmine/scm/adapters/git_adapter_spec.rb b/spec/legacy/unit/lib/redmine/scm/adapters/git_adapter_spec.rb index aa986c103f9..d56e5e55ec2 100644 --- a/spec/legacy/unit/lib/redmine/scm/adapters/git_adapter_spec.rb +++ b/spec/legacy/unit/lib/redmine/scm/adapters/git_adapter_spec.rb @@ -32,7 +32,7 @@ require 'legacy_spec_helper' -describe Redmine::Scm::Adapters::GitAdapter, type: :model do +describe OpenProject::Scm::Adapters::Git, type: :model do let(:git_repository_path) { Rails.root.to_s.gsub(%r{config\/\.\.}, '') + '/tmp/test/git_repository' } FELIX_UTF8 = 'Felix Schäfer' @@ -48,7 +48,7 @@ describe Redmine::Scm::Adapters::GitAdapter, type: :model do before do skip 'Git test repository NOT FOUND. Skipping unit tests !!!' unless File.directory?(git_repository_path) - @adapter = Redmine::Scm::Adapters::GitAdapter.new( + @adapter = OpenProject::Scm::Adapters::Git.new( git_repository_path, nil, nil, @@ -88,7 +88,7 @@ describe Redmine::Scm::Adapters::GitAdapter, type: :model do end it 'should getting all revisions' do - assert_equal 21, @adapter.revisions('', nil, nil, all: true).length + assert_equal 22, @adapter.revisions('', nil, nil, all: true).length end it 'should getting certain revisions' do @@ -97,15 +97,16 @@ describe Redmine::Scm::Adapters::GitAdapter, type: :model do it 'should revisions reverse' do revs1 = @adapter.revisions('', nil, nil, all: true, reverse: true) - assert_equal 21, revs1.length + assert_equal 22, revs1.length assert_equal '7234cb2750b63f47bff735edc50a1c0a433c2518', revs1[0].identifier assert_equal '1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127', revs1[20].identifier since2 = Time.gm(2010, 9, 30, 0, 0, 0) revs2 = @adapter.revisions('', nil, nil, all: true, since: since2, reverse: true) - assert_equal 6, revs2.length + assert_equal 7, revs2.length assert_equal '67e7792ce20ccae2e4bb73eed09bb397819c8834', revs2[0].identifier assert_equal '1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127', revs2[5].identifier + assert_equal '71e5c1d3dca6304805b143b9d0e6695fb3895ea4', revs2[6].identifier end it 'should getting revisions with spaces in filename' do @@ -127,7 +128,7 @@ describe Redmine::Scm::Adapters::GitAdapter, type: :model do it 'should annotate' do annotate = @adapter.annotate('sources/watchers_controller.rb') - assert_kind_of Redmine::Scm::Adapters::Annotate, annotate + assert_kind_of OpenProject::Scm::Adapters::Annotate, annotate assert_equal 41, annotate.lines.size assert_equal '# This program is free software; you can redistribute it and/or', annotate.lines[4].strip @@ -138,7 +139,7 @@ describe Redmine::Scm::Adapters::GitAdapter, type: :model do it 'should annotate moved file' do annotate = @adapter.annotate('renamed_test.txt') - assert_kind_of Redmine::Scm::Adapters::Annotate, annotate + assert_kind_of OpenProject::Scm::Adapters::Annotate, annotate assert_equal 2, annotate.lines.size end @@ -169,8 +170,7 @@ describe Redmine::Scm::Adapters::GitAdapter, type: :model do assert_equal '2010-09-18 19:59:46'.to_time, last_rev.time end - # TODO: need to handle edge cases of non-binary content that isn't UTF-8 - xit 'test latin 1 path' do + it 'test latin 1 path' do if WINDOWS_PASS # else @@ -248,7 +248,7 @@ describe Redmine::Scm::Adapters::GitAdapter, type: :model do private def test_scm_version_for(scm_command_version, version) - expect(@adapter.class).to receive(:scm_version_from_command_line).and_return(scm_command_version) - assert_equal version, @adapter.class.scm_command_version + expect(@adapter).to receive(:scm_version_from_command_line).and_return(scm_command_version) + assert_equal version, @adapter.git_binary_version end end diff --git a/spec/legacy/unit/lib/redmine/scm/adapters/subversion_adapter_spec.rb b/spec/legacy/unit/lib/redmine/scm/adapters/subversion_adapter_spec.rb index 567dea34597..7083d722481 100644 --- a/spec/legacy/unit/lib/redmine/scm/adapters/subversion_adapter_spec.rb +++ b/spec/legacy/unit/lib/redmine/scm/adapters/subversion_adapter_spec.rb @@ -29,14 +29,14 @@ require 'legacy_spec_helper' -describe Redmine::Scm::Adapters::SubversionAdapter, type: :model do +describe OpenProject::Scm::Adapters::Subversion, type: :model do if repository_configured?('subversion') before do - @adapter = Redmine::Scm::Adapters::SubversionAdapter.new(self.class.subversion_repository_url) + @adapter = OpenProject::Scm::Adapters::Subversion.new(self.class.subversion_repository_url) end it 'should client version' do - v = Redmine::Scm::Adapters::SubversionAdapter.client_version + v = @adapter.client_version assert v.is_a?(Array) end @@ -53,8 +53,8 @@ describe Redmine::Scm::Adapters::SubversionAdapter, type: :model do private def test_scm_version_for(scm_version, version) - expect(@adapter.class).to receive(:scm_version_from_command_line).and_return(scm_version) - assert_equal version, @adapter.class.svn_binary_version + expect(@adapter).to receive(:scm_version_from_command_line).and_return(scm_version) + assert_equal version, @adapter.svn_binary_version end else diff --git a/spec/legacy/unit/repository_filesystem_spec.rb b/spec/legacy/unit/repository_filesystem_spec.rb deleted file mode 100644 index 8b1f2e1107d..00000000000 --- a/spec/legacy/unit/repository_filesystem_spec.rb +++ /dev/null @@ -1,65 +0,0 @@ -#-- encoding: UTF-8 -#-- copyright -# OpenProject is a project management system. -# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See doc/COPYRIGHT.rdoc for more details. -#++ -require 'legacy_spec_helper' - -describe Repository::Filesystem, type: :model do - fixtures :all - - before do - @project = Project.find(3) - - with_existing_filesystem_scm do |repo_path| - assert @repository = Repository::Filesystem.create(project: @project, - url: repo_path) - end - end - - it 'should fetch changesets' do - with_existing_filesystem_scm do - @repository.fetch_changesets - @repository.reload - - assert_equal 0, @repository.changesets.count - assert_equal 0, @repository.changes.count - end - end - - it 'should entries' do - with_existing_filesystem_scm do - assert_equal 3, @repository.entries('', 2).size - assert_equal 2, @repository.entries('dir', 3).size - end - end - - it 'should cat' do - with_existing_filesystem_scm do - assert_equal "TEST CAT\n", @repository.scm.cat('test') - end - end -end diff --git a/spec/legacy/unit/repository_git_spec.rb b/spec/legacy/unit/repository_git_spec.rb index 89911c55a0a..d4dafe3742b 100644 --- a/spec/legacy/unit/repository_git_spec.rb +++ b/spec/legacy/unit/repository_git_spec.rb @@ -53,6 +53,7 @@ describe Repository::Git, type: :model do @project = Project.find(3) @repository = Repository::Git.create( project: @project, + scm_type: 'local', url: git_repository_path, path_encoding: 'ISO-8859-1' ) @@ -67,8 +68,8 @@ describe Repository::Git, type: :model do @repository.fetch_changesets @repository.reload - assert_equal 21, @repository.changesets.count - assert_equal 33, @repository.changes.count + assert_equal 22, @repository.changesets.count + assert_equal 34, @repository.changes.count commit = @repository.changesets.reorder('committed_on ASC').first assert_equal "Initial import.\nThe repository contains 3 files.", commit.comments @@ -91,19 +92,19 @@ describe Repository::Git, type: :model do @repository.changesets.order('committed_on DESC').limit(8).each(&:destroy) @repository.reload cs1 = @repository.changesets - assert_equal 13, cs1.count + assert_equal 14, cs1.count rev_a_commit = @repository.changesets.order('committed_on DESC').first - assert_equal '4f26664364207fa8b1af9f8722647ab2d4ac5d43', rev_a_commit.revision + assert_equal 'ed5bb786bbda2dee66a2d50faf51429dbc043a7b', rev_a_commit.revision # Mon Jul 5 22:34:26 2010 +0200 - rev_a_committed_on = Time.gm(2010, 7, 5, 20, 34, 26) - assert_equal '4f26664364207fa8b1af9f8722647ab2d4ac5d43', rev_a_commit.scmid + rev_a_committed_on = Time.gm(2010, 9, 18, 19, 59, 46) + assert_equal 'ed5bb786bbda2dee66a2d50faf51429dbc043a7b', rev_a_commit.scmid assert_equal rev_a_committed_on, rev_a_commit.committed_on latest_rev = @repository.latest_changeset assert_equal rev_a_committed_on, latest_rev.committed_on @repository.fetch_changesets - assert_equal 21, @repository.changesets.count + assert_equal 22, @repository.changesets.count end it 'should latest changesets' do @@ -320,7 +321,7 @@ describe Repository::Git, type: :model do it 'should next nil' do @repository.fetch_changesets @repository.reload - %w|1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127 1ca7f5ed|.each do |r1| + %w|71e5c1d3dca6304805b143b9d0e6695fb3895ea4 71e5c1d3|.each do |r1| changeset = @repository.find_changeset_by_name(r1) assert_nil changeset.next end diff --git a/spec/legacy/unit/repository_spec.rb b/spec/legacy/unit/repository_spec.rb index 9fbc75cc15d..da979020d85 100644 --- a/spec/legacy/unit/repository_spec.rb +++ b/spec/legacy/unit/repository_spec.rb @@ -42,7 +42,7 @@ describe Repository, type: :model do end it 'should create' do - repository = Repository::Subversion.new(project: Project.find(3)) + repository = Repository::Subversion.new(project: Project.find(3), scm_type: 'existing') assert !repository.save repository.url = 'svn://localhost' @@ -68,7 +68,7 @@ describe Repository, type: :model do it 'should not create with disabled scm' do Setting.enabled_scm = ['Git'] # disable Subversion - repository = Repository::Subversion.new(project: Project.find(3), url: 'svn://localhost') + repository = Repository::Subversion.new(project: Project.find(3), scm_type: 'existing', url: 'svn://localhost') assert !repository.save assert_includes repository.errors[:type], I18n.translate('activerecord.errors.messages.invalid') # re-enable Subversion for following tests @@ -120,7 +120,11 @@ describe Repository, type: :model do end it 'should for changeset comments strip' do - repository = Repository::Subversion.create(project: Project.find(4), url: 'svn://:login:password@host:/path/to/the/repository') + repository = Repository::Subversion.create( + project: Project.find(4), + scm_type: 'existing', + url: 'svn://:login:password@host:/path/to/the/repository' + ) comment = 'This is a looooooooooooooong comment' + (' ' * 80 + "\n") * 5 changeset = Changeset.new( comments: comment, commit_date: Time.now, revision: 0, scmid: 'f39b7922fb3c', @@ -134,6 +138,7 @@ describe Repository, type: :model do repository = Repository::Subversion.create( project: Project.find(4), url: ' svn://:login:password@host:/path/to/the/repository', + scm_type: 'existing', log_encoding: 'UTF-8') repository.root_url = 'foo ' # can't mass-assign this attr assert repository.save diff --git a/spec/legacy/unit/repository_subversion_spec.rb b/spec/legacy/unit/repository_subversion_spec.rb index f850daac219..297fb95c0dd 100644 --- a/spec/legacy/unit/repository_subversion_spec.rb +++ b/spec/legacy/unit/repository_subversion_spec.rb @@ -36,6 +36,7 @@ describe Repository::Subversion, type: :model do @project = Project.find(3) @repository = Repository::Subversion.create(project: @project, + scm_type: 'existing', url: self.class.subversion_repository_url) assert @repository end @@ -44,8 +45,8 @@ describe Repository::Subversion, type: :model do @repository.fetch_changesets @repository.reload - assert_equal 11, @repository.changesets.count - assert_equal 20, @repository.changes.count + assert_equal 12, @repository.changesets.count + assert_equal 21, @repository.changes.count assert_equal 'Initial import.', @repository.changesets.find_by(revision: '1').comments end @@ -57,7 +58,7 @@ describe Repository::Subversion, type: :model do assert_equal 5, @repository.changesets.count @repository.fetch_changesets - assert_equal 11, @repository.changesets.count + assert_equal 12, @repository.changesets.count end it 'should latest changesets' do @@ -91,6 +92,7 @@ describe Repository::Subversion, type: :model do @project = Project.find(3) @repository = Repository::Subversion.create( project: @project, + scm_type: 'local', url: "file:///#{self.class.repository_path('subversion')}/subversion_test/[folder_with_brackets]") @repository.fetch_changesets @@ -197,7 +199,7 @@ describe Repository::Subversion, type: :model do it 'should next nil' do @repository.fetch_changesets @repository.reload - changeset = @repository.find_changeset_by_name('11') + changeset = @repository.find_changeset_by_name('12') assert_nil changeset.next end diff --git a/spec/legacy/unit/user_spec.rb b/spec/legacy/unit/user_spec.rb index 0f87ad4dab3..181565a50d1 100644 --- a/spec/legacy/unit/user_spec.rb +++ b/spec/legacy/unit/user_spec.rb @@ -371,7 +371,7 @@ describe User, type: :model do @jsmith.notified_project_ids = [] @jsmith.save @jsmith.reload - assert @jsmith.projects.first.recipients.include?(@jsmith.mail) + assert @jsmith.projects.first.recipients.include?(@jsmith) end it 'should mail notification selected' do @@ -379,7 +379,7 @@ describe User, type: :model do @jsmith.notified_project_ids = [1] @jsmith.save @jsmith.reload - assert Project.find(1).recipients.include?(@jsmith.mail) + assert Project.find(1).recipients.include?(@jsmith) end it 'should mail notification only my events' do @@ -387,7 +387,7 @@ describe User, type: :model do @jsmith.notified_project_ids = [] @jsmith.save @jsmith.reload - assert !@jsmith.projects.first.recipients.include?(@jsmith.mail) + assert !@jsmith.projects.first.recipients.include?(@jsmith) end it 'should comments sorting preference' do diff --git a/spec/legacy/unit/watcher_spec.rb b/spec/legacy/unit/watcher_spec.rb index 15c1c12d6b2..4536b0613f4 100644 --- a/spec/legacy/unit/watcher_spec.rb +++ b/spec/legacy/unit/watcher_spec.rb @@ -90,10 +90,10 @@ describe Watcher do assert @issue.watcher_recipients.empty? assert @issue.add_watcher(@user) - assert_contains @issue.watcher_recipients, @user.mail + assert_contains @issue.watcher_recipients, @user @user.update_attribute :mail_notification, 'none' - assert_does_not_contain @issue.watcher_recipients, @user.mail + assert_does_not_contain @issue.watcher_recipients, @user end it 'should unwatch' do diff --git a/spec/legacy_spec_helper.rb b/spec/legacy_spec_helper.rb index 348267dfe50..2dd6c1ec5bd 100644 --- a/spec/legacy_spec_helper.rb +++ b/spec/legacy_spec_helper.rb @@ -40,8 +40,10 @@ require 'factory_girl_rails' require_relative './support/file_helpers' require_relative './support/shared/with_mock_request' require_relative './legacy/support/legacy_assertions' +require_relative './support/repository_helpers' require_relative './legacy/support/object_daddy_helpers' + include ObjectDaddyHelpers require 'rspec/rails' diff --git a/spec/lib/acts_as_journalized/journaled_spec.rb b/spec/lib/acts_as_journalized/journaled_spec.rb index fac3dafc55a..c311d59bd53 100644 --- a/spec/lib/acts_as_journalized/journaled_spec.rb +++ b/spec/lib/acts_as_journalized/journaled_spec.rb @@ -96,7 +96,8 @@ describe 'Journalized Objects' do it 'should work with changesets' do Setting.enabled_scm = ['Subversion'] - @repository ||= Repository.factory('Subversion', url: 'http://svn.test.com') + @repository ||= Repository.build_scm_class('Subversion').new + @repository.assign_attributes(scm_type: 'existing', url: 'http://svn.test.com') @repository.save! @changeset ||= FactoryGirl.create(:changeset, committer: @current.login, repository: @repository) diff --git a/spec/lib/api/v3/activities/activity_representer_spec.rb b/spec/lib/api/v3/activities/activity_representer_spec.rb index 379c08727f2..513c8a44dd3 100644 --- a/spec/lib/api/v3/activities/activity_representer_spec.rb +++ b/spec/lib/api/v3/activities/activity_representer_spec.rb @@ -31,12 +31,17 @@ require 'spec_helper' describe ::API::V3::Activities::ActivityRepresenter do let(:current_user) { FactoryGirl.create(:user, member_in_project: project, member_through_role: role) } let(:work_package) { FactoryGirl.build(:work_package) } - let(:journal) { FactoryGirl.build(:work_package_journal, journable: work_package, user: current_user) } + let(:journal) { Journal::AggregatedJournal.aggregated_journals.first } let(:project) { work_package.project } let(:permissions) { %i(edit_own_work_package_notes) } let(:role) { FactoryGirl.create :role, permissions: permissions } let(:representer) { described_class.new(journal, current_user: current_user) } + before do + allow(User).to receive(:current).and_return(current_user) + work_package.save! + end + context 'generation' do subject(:generated) { representer.to_json } diff --git a/spec/lib/api/v3/repositories/revision_representer_spec.rb b/spec/lib/api/v3/repositories/revision_representer_spec.rb new file mode 100644 index 00000000000..08847fda85c --- /dev/null +++ b/spec/lib/api/v3/repositories/revision_representer_spec.rb @@ -0,0 +1,127 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe ::API::V3::Repositories::RevisionRepresenter do + include ::API::V3::Utilities::PathHelper + + let(:representer) { described_class.new(revision) } + + let(:project) { FactoryGirl.build :project } + let(:repository) { FactoryGirl.build :repository_subversion, project: project } + let(:revision) { + FactoryGirl.build(:changeset, + id: 42, + revision: '1234', + repository: repository, + comments: commit_message, + committer: 'foo bar ', + committed_on: DateTime.now, + ) + } + + let(:commit_message) { 'Some commit message' } + + context 'generation' do + subject(:generated) { representer.to_json } + + it { is_expected.to be_json_eql('Revision'.to_json).at_path('_type') } + + describe 'revision' do + it { is_expected.to have_json_path('id') } + + it_behaves_like 'API V3 formattable', 'message' do + let(:format) { 'plain' } + let(:raw) { revision.comments } + let(:html) { '

    ' + revision.comments + '

    ' } + end + + describe 'identifier' do + it { is_expected.to have_json_path('identifier') } + it { is_expected.to be_json_eql('1234'.to_json).at_path('identifier') } + end + + describe 'createdAt' do + it_behaves_like 'has UTC ISO 8601 date and time' do + let(:date) { revision.committed_on } + let(:json_path) { 'createdAt' } + end + end + + describe 'authorName' do + it { is_expected.to have_json_path('authorName') } + it { is_expected.to be_json_eql('foo bar '.to_json).at_path('authorName') } + end + end + + context 'with referencing commit message' do + let(:work_package) { FactoryGirl.build_stubbed(:work_package, project: project) } + let(:commit_message) { "Totally references ##{work_package.id}" } + let(:html_reference) { + id = work_package.id + + str = "Totally references " + str << "##{id}" + } + + before do + allow(WorkPackage).to receive(:find_by_id).and_return(work_package) + end + + it_behaves_like 'API V3 formattable', 'message' do + let(:format) { 'plain' } + let(:raw) { revision.comments } + let(:html) { '

    ' + html_reference + '

    ' } + end + end + + describe 'author' do + context 'with no linked user' do + it_behaves_like 'has no link' do + let(:link) { 'author' } + end + end + + context 'with linked user as author' do + let(:user) { FactoryGirl.build(:user) } + before do + allow(revision).to receive(:user).and_return(user) + end + + it_behaves_like 'has a titled link' do + let(:link) { 'author' } + let(:href) { api_v3_paths.user(user.id) } + let(:title) { user.name } + end + end + end + end +end diff --git a/spec/lib/api/v3/utilities/path_helper_spec.rb b/spec/lib/api/v3/utilities/path_helper_spec.rb index f91c65b5d45..a8f7808ac67 100644 --- a/spec/lib/api/v3/utilities/path_helper_spec.rb +++ b/spec/lib/api/v3/utilities/path_helper_spec.rb @@ -31,8 +31,21 @@ require 'spec_helper' describe ::API::V3::Utilities::PathHelper do let(:helper) { Class.new.tap { |c| c.extend(::API::V3::Utilities::PathHelper) }.api_v3_paths } - shared_examples_for 'api v3 path' do - it { is_expected.to match(/^\/api\/v3/) } + shared_examples_for 'path' do |url| + it 'provides the path' do + is_expected.to match(url) + end + + it 'prepends the sub uri if configured' do + allow(OpenProject::Configuration).to receive(:rails_relative_url_root) + .and_return('/open_project') + + is_expected.to match("/open_project#{url}") + end + end + + shared_examples_for 'api v3 path' do |url| + it_behaves_like 'path', "/api/v3#{url}" end describe '#root' do @@ -44,87 +57,67 @@ describe ::API::V3::Utilities::PathHelper do describe '#activity' do subject { helper.activity 1 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/activities\/1/) } + it_behaves_like 'api v3 path', '/activities/1' end describe '#attachment' do subject { helper.attachment 1 } - it_behaves_like 'api v3 path' - - it { is_expected.to eql('/api/v3/attachments/1') } + it_behaves_like 'api v3 path', '/attachments/1' end describe '#attachment_download' do subject { helper.attachment_download 1 } - it { is_expected.to eql('/attachments/1') } + it_behaves_like 'path', '/attachments/1' end describe '#attachments_by_work_package' do subject { helper.attachments_by_work_package 1 } - it_behaves_like 'api v3 path' - - it { is_expected.to eql('/api/v3/work_packages/1/attachments') } + it_behaves_like 'api v3 path', '/work_packages/1/attachments' end describe '#available_assignees' do subject { helper.available_assignees 42 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/projects\/42\/available_assignees/) } + it_behaves_like 'api v3 path', '/projects/42/available_assignees' end describe '#available_responsibles' do subject { helper.available_responsibles 42 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/projects\/42\/available_responsibles/) } + it_behaves_like 'api v3 path', '/projects/42/available_responsibles' end describe '#available_watchers' do subject { helper.available_watchers 42 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/work_packages\/42\/available_watchers/) } + it_behaves_like 'api v3 path', '/work_packages/42/available_watchers' end describe '#categories' do subject { helper.categories 42 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/projects\/42\/categories/) } + it_behaves_like 'api v3 path', '/projects/42/categories' end describe '#category' do subject { helper.category 42 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/categories\/42/) } + it_behaves_like 'api v3 path', '/categories/42' end describe '#configuration' do subject { helper.configuration } - it_behaves_like 'api v3 path' - - it { is_expected.to eql('/api/v3/configuration') } + it_behaves_like 'api v3 path', '/configuration' end describe '#create_work_package_form' do subject { helper.create_work_package_form 42 } - it_behaves_like 'api v3 path' - - it { is_expected.to eql('/api/v3/projects/42/work_packages/form') } + it_behaves_like 'api v3 path', '/projects/42/work_packages/form' end describe '#render_markup' do @@ -134,9 +127,7 @@ describe ::API::V3::Utilities::PathHelper do allow(Setting).to receive(:text_formatting).and_return('by-the-settings') end - it_behaves_like 'api v3 path' - - it { is_expected.to eql('/api/v3/render/super_fancy?context=link-ish') } + it_behaves_like 'api v3 path', '/render/super_fancy?context=link-ish' context 'no link given' do subject { helper.render_markup(format: 'super_fancy') } @@ -165,17 +156,13 @@ describe ::API::V3::Utilities::PathHelper do describe '#priorities' do subject { helper.priorities } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/priorities/) } + it_behaves_like 'api v3 path', '/priorities' end describe '#priority' do subject { helper.priority 1 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/priorities\/1/) } + it_behaves_like 'api v3 path', '/priorities/1' end end @@ -183,59 +170,53 @@ describe ::API::V3::Utilities::PathHelper do describe '#projects' do subject { helper.projects } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/projects/) } + it_behaves_like 'api v3 path', '/projects' end describe '#project' do subject { helper.project 1 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/projects\/1/) } + it_behaves_like 'api v3 path', '/projects/1' end end describe '#query' do subject { helper.query 1 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/queries\/1/) } + it_behaves_like 'api v3 path', '/queries/1' end describe '#query_star' do subject { helper.query_star 1 } - it_behaves_like 'api v3 path' - - it { is_expected.to eql('/api/v3/queries/1/star') } + it_behaves_like 'api v3 path', '/queries/1/star' end describe '#query_unstar' do subject { helper.query_unstar 1 } - it_behaves_like 'api v3 path' - - it { is_expected.to eql('/api/v3/queries/1/unstar') } + it_behaves_like 'api v3 path', '/queries/1/unstar' end describe 'relations paths' do describe '#relation' do subject { helper.relation 1 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/relations/) } + it_behaves_like 'api v3 path', '/relations' end describe '#relation' do subject { helper.relation 1 } - it_behaves_like 'api v3 path' + it_behaves_like 'api v3 path', '/relations/1' + end + end - it { is_expected.to match(/^\/api\/v3\/relations\/1/) } + describe 'revisions paths' do + describe '#revision' do + subject { helper.revision 1 } + + it_behaves_like 'api v3 path', '/revisions/1' end end @@ -243,9 +224,7 @@ describe ::API::V3::Utilities::PathHelper do describe '#work_package_schema' do subject { helper.work_package_schema 1, 2 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/work_packages\/schemas\/1-2/) } + it_behaves_like 'api v3 path', '/work_packages/schemas/1-2' end end @@ -253,17 +232,13 @@ describe ::API::V3::Utilities::PathHelper do describe '#statuses' do subject { helper.statuses } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/statuses/) } + it_behaves_like 'api v3 path', '/statuses' end describe '#status' do subject { helper.status 1 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/statuses\/1/) } + it_behaves_like 'api v3 path', '/statuses/1' end end @@ -271,9 +246,7 @@ describe ::API::V3::Utilities::PathHelper do describe '#string_object' do subject { helper.string_object 'foo' } - it_behaves_like 'api v3 path' - - it { is_expected.to eql('/api/v3/string_objects?value=foo') } + it_behaves_like 'api v3 path', '/string_objects?value=foo' it 'escapes correctly' do value = 'foo/bar baz' @@ -284,9 +257,7 @@ describe ::API::V3::Utilities::PathHelper do describe '#status' do subject { helper.status 1 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/statuses\/1/) } + it_behaves_like 'api v3 path', '/statuses/1' end end @@ -294,133 +265,105 @@ describe ::API::V3::Utilities::PathHelper do describe '#types' do subject { helper.types } - it_behaves_like 'api v3 path' - - it { is_expected.to eql('/api/v3/types') } + it_behaves_like 'api v3 path', '/types' end describe '#types_by_project' do subject { helper.types_by_project 12 } - it_behaves_like 'api v3 path' - - it { is_expected.to eql('/api/v3/projects/12/types') } + it_behaves_like 'api v3 path', '/projects/12/types' end describe '#type' do subject { helper.type 1 } - it_behaves_like 'api v3 path' - - it { is_expected.to eql('/api/v3/types/1') } + it_behaves_like 'api v3 path', '/types/1' end end describe '#user' do subject { helper.user 1 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/users\/1/) } + it_behaves_like 'api v3 path', '/users/1' end describe '#version' do subject { helper.version 42 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/versions\/42/) } + it_behaves_like 'api v3 path', '/versions/42' end describe '#versions_by_project' do subject { helper.versions_by_project 42 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/projects\/42\/versions/) } + it_behaves_like 'api v3 path', '/projects/42/versions' end describe '#projects_by_version' do subject { helper.projects_by_version 42 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/versions\/42\/projects/) } + it_behaves_like 'api v3 path', '/versions/42/projects' end describe '#work_packages_by_project' do subject { helper.work_packages_by_project 42 } - it_behaves_like 'api v3 path' - - it { is_expected.to match(/^\/api\/v3\/projects\/42\/work_packages/) } + it_behaves_like 'api v3 path', '/projects/42/work_packages' end describe 'work packages paths' do - shared_examples_for 'api v3 work packages path' do - it { is_expected.to match(/^\/api\/v3\/work_packages/) } - end - describe '#work_packages' do subject { helper.work_packages } - it_behaves_like 'api v3 work packages path' + it_behaves_like 'api v3 path', '/work_packages' end describe '#work_package' do subject { helper.work_package 1 } - it_behaves_like 'api v3 work packages path' - - it { is_expected.to match(/^\/api\/v3\/work_packages\/1/) } + it_behaves_like 'api v3 path', '/work_packages/1' end describe '#work_package_activities' do subject { helper.work_package_activities 42 } - it_behaves_like 'api v3 work packages path' - - it { is_expected.to match(/^\/api\/v3\/work_packages\/42\/activities/) } + it_behaves_like 'api v3 path', '/work_packages/42/activities' end describe '#work_package_relations' do subject { helper.work_package_relations 42 } - it_behaves_like 'api v3 work packages path' - - it { is_expected.to match(/^\/api\/v3\/work_packages\/42\/relations/) } + it_behaves_like 'api v3 path', '/work_packages/42/relations' end describe '#work_package_relation' do subject { helper.work_package_relation 1, 42 } - it_behaves_like 'api v3 work packages path' + it_behaves_like 'api v3 path', '/work_packages/42/relations/1' + end - it { is_expected.to match(/^\/api\/v3\/work_packages\/42\/relations\/1/) } + describe '#work_package_revisions' do + subject { helper.work_package_revisions 42 } + + it_behaves_like 'api v3 path', '/work_packages/42/revisions' end describe '#work_package_form' do subject { helper.work_package_form 1 } - it_behaves_like 'api v3 work packages path' - - it { is_expected.to match(/^\/api\/v3\/work_packages\/1\/form/) } + it_behaves_like 'api v3 path', '/work_packages/1/form' end describe '#work_package_watchers' do subject { helper.work_package_watchers 1 } - it_behaves_like 'api v3 work packages path' - - it { is_expected.to match(/^\/api\/v3\/work_packages\/1\/watchers/) } + it_behaves_like 'api v3 path', '/work_packages/1/watchers' end describe '#watcher' do subject { helper.watcher 1, 42 } - it_behaves_like 'api v3 work packages path' - - it { is_expected.to match(/^\/api\/v3\/work_packages\/42\/watchers\/1/) } + it_behaves_like 'api v3 path', '/work_packages/42/watchers/1' end end end diff --git a/spec/lib/api/v3/work_packages/work_package_representer_spec.rb b/spec/lib/api/v3/work_packages/work_package_representer_spec.rb index e5a1c1249a1..6c8b07a0f40 100644 --- a/spec/lib/api/v3/work_packages/work_package_representer_spec.rb +++ b/spec/lib/api/v3/work_packages/work_package_representer_spec.rb @@ -87,7 +87,7 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do end context 'no start date' do - let(:work_package) { FactoryGirl.build(:work_package, start_date: nil) } + let(:work_package) { FactoryGirl.build(:work_package, id: 42, start_date: nil) } it 'renders as null' do is_expected.to be_json_eql(nil.to_json).at_path('startDate') @@ -102,7 +102,7 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do end context 'no due date' do - let(:work_package) { FactoryGirl.build(:work_package, due_date: nil) } + let(:work_package) { FactoryGirl.build(:work_package, id: 42, due_date: nil) } it 'renders as null' do is_expected.to be_json_eql(nil.to_json).at_path('dueDate') @@ -288,7 +288,7 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do describe 'assignee' do context 'assignee is set' do let(:work_package) { - FactoryGirl.build(:work_package, assigned_to: FactoryGirl.build(:user)) + FactoryGirl.build(:work_package, id: 42, assigned_to: FactoryGirl.build(:user)) } it_behaves_like 'has a titled link' do @@ -308,7 +308,7 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do describe 'responsible' do context 'responsible is set' do let(:work_package) { - FactoryGirl.build(:work_package, responsible: FactoryGirl.build(:user)) + FactoryGirl.build(:work_package, id: 42, responsible: FactoryGirl.build(:user)) } it_behaves_like 'has a titled link' do @@ -325,6 +325,26 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do end end + describe 'revisions' do + context 'when the user lacks the view_changesets permission' do + it_behaves_like 'has no link' do + let(:link) { 'revisions' } + end + end + + context 'when the user has the required permission' do + let(:revision_permissions) { [:view_changesets] } + let(:role) { FactoryGirl.create :role, permissions: permissions + revision_permissions } + + it_behaves_like 'has an untitled link' do + let(:link) { 'revisions' } + let(:href) { + api_v3_paths.work_package_revisions(work_package.id) + } + end + end + end + describe 'version' do let(:embedded_path) { '_embedded/version' } let(:href_path) { '_links/version/href' } diff --git a/spec/lib/open_project/scm/adapters/adapter_helper.rb b/spec/lib/open_project/scm/adapters/adapter_helper.rb new file mode 100644 index 00000000000..8b7401ae986 --- /dev/null +++ b/spec/lib/open_project/scm/adapters/adapter_helper.rb @@ -0,0 +1,58 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +module Api + module V2 + module Pagination + module PaginationSpecHelper + def paginating_index_action(model, scope) + describe '#index', type: :controller do + let(:params) { + { 'page' => '1', + 'page_limit' => '10', + 'q' => 'blubs', + 'format' => 'json' } + } + + before do + expect(model).to receive(scope) + .with(params['q']) + .and_return(model) + + get :index, params + end + + it 'should be successful' do + expect(response).to be_success + end + end + end + end + end + end +end diff --git a/spec/lib/open_project/scm/adapters/git_adapter_spec.rb b/spec/lib/open_project/scm/adapters/git_adapter_spec.rb new file mode 100644 index 00000000000..42601fe4fd8 --- /dev/null +++ b/spec/lib/open_project/scm/adapters/git_adapter_spec.rb @@ -0,0 +1,485 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe OpenProject::Scm::Adapters::Git do + let(:url) { Rails.root.join('/tmp/does/not/exist.git').to_s } + let(:config) { {} } + let(:encoding) { nil } + let(:adapter) { + OpenProject::Scm::Adapters::Git.new( + url, + nil, + nil, + nil, + encoding + ) + } + + before do + allow(adapter.class).to receive(:config).and_return(config) + end + + describe 'client information' do + it 'sets the Git client command' do + expect(adapter.client_command).to eq('git') + end + + context 'with client command from config' do + let(:config) { { client_command: '/usr/local/bin/git' } } + it 'overrides the Git client command from config' do + expect(adapter.client_command).to eq('/usr/local/bin/git') + end + end + + shared_examples 'correct client version' do |git_string, expected_version| + it 'should set the correct client version' do + expect(adapter) + .to receive(:scm_version_from_command_line) + .and_return(git_string) + + expect(adapter.client_version).to eq(expected_version) + expect(adapter.client_available).to be true + expect(adapter.client_version_string).to eq(expected_version.join('.')) + end + end + + it_behaves_like 'correct client version', "git version 1.7.3.4\n", [1, 7, 3, 4] + it_behaves_like 'correct client version', "1.6.1\n1.7\n1.8", [1, 6, 1] + it_behaves_like 'correct client version', "1.6.2\r\n1.8.1\r\n1.9.1", [1, 6, 2] + end + + describe 'invalid repository' do + describe '.check_availability!' do + it 'should not be available' do + expect(Dir.exists?(url)).to be false + expect(adapter).not_to be_available + expect { adapter.check_availability! } + .to raise_error(OpenProject::Scm::Exceptions::ScmUnavailable) + end + + it 'should raise a meaningful error if shell output fails' do + expect(adapter).to receive(:branches) + .and_raise OpenProject::Scm::Exceptions::CommandFailed.new('git', '') + + expect { adapter.check_availability! } + .to raise_error(OpenProject::Scm::Exceptions::ScmUnavailable) + end + end + end + + describe 'empty repository' do + include_context 'with tmpdir' + let(:url) { tmpdir } + + before do + adapter.initialize_bare_git + end + + describe '.check_availability!' do + it 'should be marked empty' do + expect { adapter.check_availability! } + .to raise_error(OpenProject::Scm::Exceptions::ScmEmpty) + end + end + end + + describe 'local repository' do + with_git_repository do |repo_dir| + let(:url) { repo_dir } + + it 'reads the git version' do + expect(adapter.client_version.length).to be >= 3 + end + + it 'is a valid repository' do + expect(Dir.exists?(repo_dir)).to be true + + out, process = Open3.capture2e('git', '--git-dir', repo_dir, 'branch') + expect(process.exitstatus).to eq(0) + expect(out).to include('master') + end + + it 'should be available' do + expect(adapter).to be_available + expect { adapter.check_availability! }.to_not raise_error + end + + it 'should read tags' do + expect(adapter.tags).to match_array(%w[tag00.lightweight tag01.annotated]) + end + + describe '.branches' do + it 'should show the default branch' do + expect(adapter.default_branch).to eq('master') + end + + it 'should read branches' do + branches = %w[latin-1-path-encoding master test-latin-1 test_branch] + expect(adapter.branches).to match_array(branches) + end + end + + describe '.info' do + it 'builds the info object' do + info = adapter.info + expect(info.root_url).to eq(repo_dir) + expect(info.lastrev.identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4') + end + end + + describe '.lastrev' do + let(:felix_hex) { "Felix Sch\xC3\xA4fer" } + + it 'references the last revision for empty path' do + lastrev = adapter.lastrev('', nil) + expect(lastrev.identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4') + end + + it 'references the last revision of the given path' do + lastrev = adapter.lastrev('README', nil) + expect(lastrev.identifier).to eq('4a07fe31bffcf2888791f3e6cbc9c4545cefe3e8') + expect(lastrev.author).to eq('Adam Soltys ') + expect(lastrev.time).to eq('2009-06-24 07:27:38 +0200') + + # Even though that commit has a message, lastrev doesn't parse that deliberately + expect(lastrev.message).to eq('') + expect(lastrev.branch).to be_nil + expect(lastrev.paths).to be_nil + end + + it 'references the last revision of the given path and identifier' do + lastrev = adapter.lastrev('README', '4f26664364207fa8b1af9f8722647ab2d4ac5d43') + expect(lastrev.scmid).to eq('4a07fe31bffcf2888791f3e6cbc9c4545cefe3e8') + expect(lastrev.identifier).to eq('4a07fe31bffcf2888791f3e6cbc9c4545cefe3e8') + expect(lastrev.author).to eq('Adam Soltys ') + expect(lastrev.time).to eq('2009-06-24 05:27:38') + end + + it 'works with spaces in filename' do + lastrev = adapter.lastrev('filemane with spaces.txt', + 'ed5bb786bbda2dee66a2d50faf51429dbc043a7b') + expect(lastrev.identifier).to eq('ed5bb786bbda2dee66a2d50faf51429dbc043a7b') + expect(lastrev.scmid).to eq('ed5bb786bbda2dee66a2d50faf51429dbc043a7b') + expect(lastrev.time).to eq('2010-09-18 19:59:46') + end + + it 'encodes strings correctly' do + lastrev = adapter.lastrev('filemane with spaces.txt', + 'ed5bb786bbda2dee66a2d50faf51429dbc043a7b') + expect(lastrev.author).to eq('Felix Schäfer ') + expect(lastrev.author).to eq("#{felix_hex} ") + end + end + + describe '.revisions' do + it 'should retrieve all revisions' do + rev = adapter.revisions('', nil, nil, all: true) + expect(rev.length).to eq(22) + end + + it 'should retrieve the latest revision' do + rev = adapter.revisions('', nil, nil, all: true) + expect(rev.latest.identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4') + expect(rev.latest.format_identifier).to eq('71e5c1d3') + end + + it 'should retrieve a certain revisions' do + rev = adapter.revisions('', '899a15d^', '899a15d') + expect(rev.length).to eq(1) + expect(rev[0].identifier).to eq('899a15dba03a3b350b89c3f537e4bbe02a03cdc9') + expect(rev[0].author).to eq('jsmith ') + end + + it 'should retrieve revisions in reverse' do + rev = adapter.revisions('', nil, nil, all: true, reverse: true) + expect(rev.length).to eq(22) + expect(rev[0].identifier).to eq('7234cb2750b63f47bff735edc50a1c0a433c2518') + expect(rev[20].identifier).to eq('1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127') + end + + it 'should retrieve revisions in a specific time frame' do + since = Time.gm(2010, 9, 30, 0, 0, 0) + rev = adapter.revisions('', nil, nil, all: true, since: since) + expect(rev.length).to eq(7) + expect(rev[0].identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4') + expect(rev[1].identifier).to eq('1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127') + expect(rev[5].identifier).to eq('9a6f3b947d16f11b537363a60904d1b1d3bfcd2f') + expect(rev[6].identifier).to eq('67e7792ce20ccae2e4bb73eed09bb397819c8834') + end + + it 'should retrieve revisions in a specific time frame in reverse' do + since = Time.gm(2010, 9, 30, 0, 0, 0) + rev = adapter.revisions('', nil, nil, all: true, since: since, reverse: true) + expect(rev.length).to eq(7) + expect(rev[0].identifier).to eq('67e7792ce20ccae2e4bb73eed09bb397819c8834') + expect(rev[5].identifier).to eq('1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127') + expect(rev[6].identifier).to eq('71e5c1d3dca6304805b143b9d0e6695fb3895ea4') + end + + it 'should retrieve revisions by filename' do + rev = adapter.revisions('filemane with spaces.txt', nil, nil, all: true) + expect(rev.length).to eq(1) + expect(rev[0].identifier).to eq('ed5bb786bbda2dee66a2d50faf51429dbc043a7b') + end + + it 'should retrieve revisions with arbitrary whitespace' do + file = ' filename with a leading space.txt ' + rev = adapter.revisions(file, nil, nil, all: true) + expect(rev.length).to eq(1) + expect(rev[0].paths[0][:path]).to eq(file) + end + + it 'should show all paths of a revision' do + rev = adapter.revisions('', '899a15d^', '899a15d')[0] + expect(rev.paths.length).to eq(3) + expect(rev.paths[0]).to eq(action: 'M', path: 'README') + expect(rev.paths[1]).to eq(action: 'A', path: 'images/edit.png') + expect(rev.paths[2]).to eq(action: 'A', path: 'sources/welcome_controller.rb') + end + end + + describe '.entries' do + shared_examples 'retrieve entries' do + it 'should retrieve entries from an identifier' do + entries = adapter.entries('', '83ca5fd') + expect(entries.length).to eq(9) + + expect(entries[0].name).to eq('images') + expect(entries[0].kind).to eq('dir') + expect(entries[0].size).to be_nil + expect(entries[0]).to be_dir + expect(entries[0]).not_to be_file + + expect(entries[3]).to be_file + expect(entries[3].size).to eq(56) + expect(entries[3].name).to eq(' filename with a leading space.txt ') + end + + it 'should have a related revision' do + entries = adapter.entries('', '83ca5fd') + rev = entries[0].lastrev + expect(rev.identifier).to eq('deff712f05a90d96edbd70facc47d944be5897e3') + expect(rev.author).to eq('Adam Soltys ') + + rev = entries[3].lastrev + expect(rev.identifier).to eq('83ca5fd546063a3c7dc2e568ba3355661a9e2b2c') + expect(rev.author).to eq('Felix Schäfer ') + end + + it 'can be retrieved by tag' do + entries = adapter.entries(nil, 'tag01.annotated') + expect(entries.length).to eq(3) + + sources = entries[1] + expect(sources.name).to eq('sources') + expect(sources.path).to eq('sources') + expect(sources).to be_dir + + readme = entries[2] + expect(readme.name).to eq('README') + expect(readme.path).to eq('README') + expect(readme).to be_file + expect(readme.size).to eq(27) + expect(readme.lastrev.identifier).to eq('899a15dba03a3b350b89c3f537e4bbe02a03cdc9') + expect(readme.lastrev.time).to eq(Time.gm(2007, 12, 14, 9, 24, 1)) + end + + it 'can be retrieved by branch' do + entries = adapter.entries(nil, 'test_branch') + expect(entries.length).to eq(4) + sources = entries[1] + expect(sources.name).to eq('sources') + expect(sources.path).to eq('sources') + expect(sources).to be_dir + + readme = entries[2] + expect(readme.name).to eq('README') + expect(readme.path).to eq('README') + expect(readme).to be_file + expect(readme.size).to eq(159) + + expect(readme.lastrev.identifier).to eq('713f4944648826f558cf548222f813dabe7cbb04') + expect(readme.lastrev.time).to eq(Time.gm(2009, 6, 19, 4, 37, 23)) + end + end + + describe 'encoding' do + let (:char1_hex) { "\xc3\x9c".force_encoding('UTF-8') } + + context 'with default encoding' do + it_behaves_like 'retrieve entries' + + it 'can retrieve directories containing entries encoded in latin-1' do + entries = adapter.entries('latin-1-dir', '64f1f3e8') + f1 = entries[1] + + expect(f1.name).to eq("test-\xDC-2.txt") + expect(f1.path).to eq("latin-1-dir/test-\xDC-2.txt") + expect(f1).to be_file + end + + it 'cannot retrieve files with latin-1 encoding in their path' do + entries = adapter.entries('latin-1-dir', '64f1f3e8') + latin1_path = entries[1].path + + expect { adapter.entries(latin1_path, '1ca7f5ed') } + .to raise_error(OpenProject::Scm::Exceptions::CommandFailed) + end + end + + context 'with latin-1 encoding' do + let (:encoding) { 'ISO-8859-1' } + + it_behaves_like 'retrieve entries' + + it 'can be retrieved with latin-1 encoding' do + entries = adapter.entries('latin-1-dir', '64f1f3e8') + expect(entries.length).to eq(3) + f1 = entries[1] + + expect(f1.name).to eq("test-#{char1_hex}-2.txt") + expect(f1.path).to eq("latin-1-dir/test-#{char1_hex}-2.txt") + expect(f1).to be_file + end + + it 'can be retrieved with latin-1 directories' do + entries = adapter.entries("latin-1-dir/test-#{char1_hex}-subdir", + '1ca7f5ed') + expect(entries.length).to eq(3) + f1 = entries[1] + + expect(f1).to be_file + expect(f1.name).to eq("test-#{char1_hex}-2.txt") + expect(f1.path).to eq("latin-1-dir/test-#{char1_hex}-subdir/test-#{char1_hex}-2.txt") + end + end + end + end + + describe '.annotate' do + it 'should annotate a regular file' do + annotate = adapter.annotate('sources/watchers_controller.rb') + expect(annotate).to be_kind_of(OpenProject::Scm::Adapters::Annotate) + expect(annotate.lines.length).to eq(41) + expect(annotate.lines[4].strip).to eq('# This program is free software; '\ + 'you can redistribute it and/or') + expect(annotate.revisions[4].identifier).to eq('7234cb2750b63f47bff735edc50a1c0a433c2518') + expect(annotate.revisions[4].author).to eq('jsmith') + end + + it 'should annotate moved file' do + annotate = adapter.annotate('renamed_test.txt') + expect(annotate.lines.length).to eq(2) + expect(annotate.content).to eq("This is a test\nLet's pretend I'm adding a new feature!") + expect(annotate.lines).to match_array(['This is a test', + "Let's pretend I'm adding a new feature!"]) + + expect(annotate.revisions.length).to eq(2) + expect(annotate.revisions[0].identifier).to eq('fba357b886984ee71185ad2065e65fc0417d9b92') + expect(annotate.revisions[1].identifier).to eq('7e61ac704deecde634b51e59daa8110435dcb3da') + end + + it 'should annotate with identifier' do + annotate = adapter.annotate('README', 'HEAD~10') + expect(annotate.lines.length).to eq(1) + expect(annotate.empty?).to be false + expect(annotate.content).to eq("Mercurial test repository\r") + expect(annotate.revisions.length).to eq(1) + expect(annotate.revisions[0].identifier).to eq('899a15dba03a3b350b89c3f537e4bbe02a03cdc9') + expect(annotate.revisions[0].author).to eq('jsmith') + end + + it 'should raise for an invalid path' do + expect { adapter.annotate('does_not_exist.txt') } + .to raise_error(OpenProject::Scm::Exceptions::CommandFailed) + + expect { adapter.annotate('/path/outside/repository') } + .to raise_error(OpenProject::Scm::Exceptions::CommandFailed) + end + + it 'should return nil for binary path' do + expect(adapter.annotate('images/edit.png')).to be_nil + end + + # We should rethink the output of annotated files for these formats. + it 'also returns nil for UTF-16 encoded file' do + expect(adapter.annotate('utf16.txt')).to be_nil + end + end + + describe '.cat' do + it 'outputs the given file' do + out = adapter.cat('README') + expect(out).to include('Git test repository') + end + + it 'raises an exception for an invalid file' do + expect { adapter.cat('doesnotexiss') } + .to raise_error(OpenProject::Scm::Exceptions::CommandFailed) + end + end + + describe '.diff' do + it 'provides a full diff of the last commit by default' do + diff = adapter.diff('', 'HEAD') + expect(diff[0]).to eq('commit 71e5c1d3dca6304805b143b9d0e6695fb3895ea4') + expect(diff[1]).to eq("Author: Oliver G\xFCnther ") + end + + it 'provides a negative diff' do + diff = adapter.diff('', 'HEAD~2', 'HEAD~1') + expect(diff.join("\n")).to include('-And this is a file') + end + + it 'provides the complete for the given range' do + diff = adapter.diff('', '61b685f', '2f9c009') + expect(diff[1]).to eq('index 6cbd30c..b94e68e 100644') + expect(diff[10]).to eq('index 4eca635..9a541fe 100644') + end + + it 'provides the selected diff for the given range' do + diff = adapter.diff('README', '61b685f', '2f9c009') + expect(diff).to eq(<<-DIFF.strip_heredoc.split("\n")) + diff --git a/README b/README + index 6cbd30c..b94e68e 100644 + --- a/README + +++ b/README + @@ -1 +1,4 @@ + Mercurial test repository + + + +Mercurial is a distributed version control system. Mercurial is dedicated to speed and efficiency with a sane user interface. + +It is written in Python. + DIFF + end + end + end + end +end diff --git a/spec/lib/open_project/scm/adapters/subversion_adapter_spec.rb b/spec/lib/open_project/scm/adapters/subversion_adapter_spec.rb new file mode 100644 index 00000000000..c69025d5068 --- /dev/null +++ b/spec/lib/open_project/scm/adapters/subversion_adapter_spec.rb @@ -0,0 +1,405 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe OpenProject::Scm::Adapters::Subversion do + let(:root_url) { '/tmp/bar.svn' } + let(:url) { "file://#{root_url}" } + let(:config) { {} } + let(:adapter) { OpenProject::Scm::Adapters::Subversion.new url, root_url } + + before do + allow(adapter.class).to receive(:config).and_return(config) + end + + describe 'client information' do + it 'sets the Subversion client command' do + expect(adapter.client_command).to eq('svn') + end + + context 'with client command from config' do + let(:config) { { client_command: '/usr/local/bin/svn' } } + it 'overrides the Subversion client command from config' do + expect(adapter.client_command).to eq('/usr/local/bin/svn') + end + end + + shared_examples 'correct client version' do |svn_string, expected_version| + it 'should set the correct client version' do + expect(adapter) + .to receive(:scm_version_from_command_line) + .and_return(svn_string) + + expect(adapter.client_version).to eq(expected_version) + expect(adapter.client_available).to be true + expect(adapter.client_version_string).to eq(expected_version.join('.')) + end + end + + it_behaves_like 'correct client version', "svn, version 1.6.13 (r1002816)\n", [1, 6, 13] + it_behaves_like 'correct client version', "svn, versione 1.6.13 (r1002816)\n", [1, 6, 13] + it_behaves_like 'correct client version', "1.6.1\n1.7\n1.8", [1, 6, 1] + it_behaves_like 'correct client version', "1.6.2\r\n1.8.1\r\n1.9.1", [1, 6, 2] + end + + describe 'invalid repository' do + describe '.check_availability!' do + it 'should not be available' do + expect(Dir.exists?(url)).to be false + expect(adapter).not_to be_available + expect { adapter.check_availability! } + .to raise_error(OpenProject::Scm::Exceptions::ScmUnavailable) + end + + it 'should raise a meaningful error if shell output fails' do + error_string = <<-ERR.strip_heredoc + svn: E215004: Authentication failed and interactive prompting is disabled; see the --force-interactive option + svn: E215004: Unable to connect to a repository at URL 'file:///tmp/bar.svn' + svn: E215004: No more credentials or we tried too many times. + Authentication failed + ERR + + allow(adapter).to receive(:popen3) + .and_yield(StringIO.new(''), StringIO.new(error_string)) + + expect { adapter.check_availability! } + .to raise_error(OpenProject::Scm::Exceptions::ScmUnauthorized) + end + end + end + + describe 'repository with authorization' do + let(:adapter) { OpenProject::Scm::Adapters::Subversion.new url, root_url, login, password } + let(:login) { 'whatever@example.org' } + let(:svn_cmd) { adapter.send :build_svn_cmd, ['info'] } + + context 'without password' do + let(:password) { nil } + + it 'creates the subversion command' do + idx = svn_cmd.index('--username') + expect(idx).not_to be_nil + expect(svn_cmd[idx + 1]).to eq(login) + expect(svn_cmd).not_to include('--password') + end + end + + context 'with password' do + let(:password) { 'VG%\';rm -rf /;},Y^m\+DuE,vJP/9' } + + it 'creates the subversion command' do + idx = svn_cmd.index('--username') + expect(idx).not_to be_nil + expect(svn_cmd[idx + 1]).to eq(login) + + idx = svn_cmd.index('--password') + expect(idx).not_to be_nil + expect(svn_cmd[idx + 1]) + .to eq("VG\\%\\'\\;rm\\ -rf\\ /\\;\\},Y\\\\^m\\\\\\+DuE,vJP/9") + end + end + end + + describe 'empty repository' do + include_context 'with tmpdir' + let(:root_url) { tmpdir } + + describe '.create_empty_svn' do + context 'with valid root_url' do + it 'should create the repository' do + expect(Dir.exists?(root_url)).to be true + expect(Dir.entries(root_url).length).to eq 2 + expect { adapter.create_empty_svn }.not_to raise_error + + expect(Dir.exists?(root_url)).to be true + expect(Dir.entries(root_url).length).to be >= 5 + end + end + context 'with non-existing root_url' do + let(:root_url) { File.join(tmpdir, 'foo', 'bar') } + + it 'should fail' do + expect { adapter.create_empty_svn } + .to raise_error(OpenProject::Scm::Exceptions::CommandFailed) + + expect(Dir.exists?(root_url)).to be false + end + end + end + + describe '.check_availability!' do + it 'should be marked empty' do + adapter.create_empty_svn + expect { adapter.check_availability! } + .to raise_error(OpenProject::Scm::Exceptions::ScmEmpty) + end + end + end + + describe 'local repository' do + with_subversion_repository do |repo_dir| + let(:root_url) { repo_dir } + + it 'reads the Subversion version' do + expect(adapter.client_version.length).to be >= 3 + end + + it 'is a valid repository' do + expect(Dir.exists?(repo_dir)).to be true + + out, process = Open3.capture2e('svn', 'info', url) + expect(process.exitstatus).to eq(0) + expect(out).to include('Repository UUID') + end + + it 'should be available' do + expect(adapter).to be_available + expect { adapter.check_availability! }.to_not raise_error + end + + describe '.info' do + it 'builds the info object' do + info = adapter.info + expect(info.root_url).to eq(url) + expect(info.lastrev.identifier).to eq('12') + expect(info.lastrev.author).to eq('oliver') + expect(info.lastrev.time).to eq('2015-07-08T13:32:29.228572Z') + end + end + + describe '.entries' do + it 'reads all entries from the current revision' do + entries = adapter.entries + expect(entries.length).to eq(1) + + expect(entries[0].name).to eq('subversion_test') + expect(entries[0].path).to eq('subversion_test') + end + + it 'contains a reference to the last revision' do + entries = adapter.entries + expect(entries.length).to eq(1) + lastrev = entries[0].lastrev + + expect(lastrev.identifier).to eq('12') + expect(lastrev.author).to eq('oliver') + expect(lastrev.message).to eq('') + expect(lastrev.time).to eq('2015-07-08T13:32:29.228572Z') + end + + it 'reads all entries from the given revision' do + entries = adapter.entries(nil, 1) + expect(entries.length).to eq(1) + lastrev = entries[0].lastrev + + expect(lastrev.identifier).to eq('1') + expect(lastrev.author).to eq('jp') + expect(lastrev.message).to eq('') + expect(lastrev.time).to eq('2007-09-10T16:54:38.484000Z') + end + + it 'reads all entries from the given path' do + entries = adapter.entries('subversion_test') + expect(entries.length).to eq(5) + + expect(entries[0].name).to eq('[folder_with_brackets]') + expect(entries[0].path).to eq('subversion_test/[folder_with_brackets]') + expect(entries[0]).to be_dir + expect(entries[0]).not_to be_file + expect(entries[0].size).to be_nil + + expect(entries[1].name).to eq('folder') + expect(entries[1].path).to eq('subversion_test/folder') + expect(entries[1]).to be_dir + expect(entries[1]).not_to be_file + expect(entries[1].size).to be_nil + + expect(entries[4].name).to eq('textfile.txt') + expect(entries[4].path).to eq('subversion_test/textfile.txt') + expect(entries[4]).to_not be_dir + expect(entries[4]).to be_file + expect(entries[4]).not_to be_dir + expect(entries[4].size).to eq(756) + end + + it 'reads all entries from the given path and revision' do + entries = adapter.entries('subversion_test', '2') + expect(entries.length).to eq(4) + expect(entries[0].name).to eq('folder') + expect(entries[0].path).to eq('subversion_test/folder') + + expect(entries[1].name).to eq('.project') + expect(entries[1].path).to eq('subversion_test/.project') + + expect(entries[2].name).to eq('helloworld.rb') + expect(entries[2].path).to eq('subversion_test/helloworld.rb') + + expect(entries[3].name).to eq('textfile.txt') + expect(entries[3].path).to eq('subversion_test/textfile.txt') + end + end + + describe '.properties' do + it 'returns an empty hash for no properties' do + expect(adapter.properties('')).to eq({}) + end + + it 'returns the properties when available' do + expect(adapter.properties('subversion_test')).to eq('svn:ignore' => "foo\nbar/\n") + end + + it 'does not return the properties from an older revision on the same path' do + expect(adapter.properties('subversion_test', 11)).to eq({}) + end + end + + describe '.revisions' do + it 'returns all revisions by default' do + revisions = adapter.revisions + expect(revisions.length).to eq(12) + + expect(revisions[0].author).to eq('oliver') + expect(revisions[0].message).to eq("Propedit\n") + + revisions.each_with_index do |rev, i| + expect(rev.identifier).to eq((12 - i).to_s) + end + end + + it 'returns revisions for a specific path' do + revisions = adapter.revisions('subversion_test/[folder_with_brackets]', nil, nil, + with_paths: true) + + expect(revisions.length).to eq(1) + expect(revisions[0].identifier).to eq('11') + expect(revisions[0].format_identifier).to eq('11') + + paths = revisions[0].paths + expect(paths.length).to eq(2) + expect(paths[0]).to eq(action: 'A', path: '/subversion_test/[folder_with_brackets]', + from_path: nil, from_revision: nil) + end + + it 'returns revision for a specific path and revision' do + # Folder was added in rev 2 + expect { adapter.revisions('subversion_test/folder', 1) } + .to raise_error(OpenProject::Scm::Exceptions::CommandFailed) + + revisions = adapter.revisions('subversion_test/folder', 2, nil, + with_paths: true) + + expect(revisions.length).to eq(1) + expect(revisions[0].identifier).to eq('2') + + paths = revisions[0].paths + expect(paths.length).to eq(7) + end + + it 'returns revision for a specific range' do + revisions = adapter.revisions('subversion_test/folder', 2, 5, + with_paths: true) + expect(revisions.length).to eq(2) + expect(revisions[0].identifier).to eq('2') + expect(revisions[0].message).to eq('Initial import.') + expect(revisions[1].identifier).to eq('5') + expect(revisions[1].message).to eq('Modified one file in the folder.') + + expect(revisions[0].paths.length).to eq(7) + expect(revisions[1].paths.length).to eq(1) + end + end + + describe '.blame' do + it 'blames an existing file at the given path' do + annotate = adapter.annotate('subversion_test/[folder_with_brackets]/README.txt') + expect(annotate.lines.length).to eq(2) + expect(annotate.revisions.length).to eq(2) + + expect(annotate.revisions[0].identifier).to eq('11') + expect(annotate.revisions[0].author).to eq('schmidt') + end + + it 'outputs nothing for an invalid blame target' do + annotate = adapter.annotate('subversion_test/[folder_with_brackets]/README.txt', 10) + expect(annotate.lines.length).to eq(0) + expect(annotate.revisions.length).to eq(0) + end + end + + describe '.cat' do + it 'outputs the given file' do + out = adapter.cat('subversion_test/[folder_with_brackets]/README.txt', 11) + expect(out).to eq('This file should be accessible for Redmine, '\ + "although its folder contains square\nbrackets.\n") + end + + it 'raises an exception for an invalid file' do + expect { adapter.cat('subversion_test/[folder_with_brackets]/README.txt', 10) } + .to raise_error(OpenProject::Scm::Exceptions::CommandFailed) + end + end + + describe '.diff' do + it 'provides a full diff against the last revision' do + diff = adapter.diff('', 12) + expect(diff.join("\n")).to include('Added: svn:ignore') + end + + it 'provides a negative diff' do + diff = adapter.diff('', 11, 12) + expect(diff.join("\n")).to include('Deleted: svn:ignore') + end + + it 'provides the complete for the given range' do + diff = adapter.diff('', 8, 6).join("\n") + expect(diff).to include('Index: subversion_test/folder/greeter.rb') + expect(diff).to include('Index: subversion_test/helloworld.c') + end + + it 'provides the selected diff for the given range' do + diff = adapter.diff('subversion_test/helloworld.c', 8, 6) + expect(diff).to eq(<<-DIFF.strip_heredoc.split("\n")) + Index: helloworld.c + =================================================================== + --- helloworld.c (revision 6) + +++ helloworld.c (revision 8) + @@ -3,6 +3,5 @@ + int main(void) + { + printf("hello, world\\n"); + - + return 0; + } + DIFF + end + end + end + end +end diff --git a/spec/workers/mail_notification_jobs/deliver_work_package_created_job_spec.rb b/spec/lib/open_project/scm/manager_spec.rb similarity index 69% rename from spec/workers/mail_notification_jobs/deliver_work_package_created_job_spec.rb rename to spec/lib/open_project/scm/manager_spec.rb index db89b0efe7c..4942623f6d5 100644 --- a/spec/workers/mail_notification_jobs/deliver_work_package_created_job_spec.rb +++ b/spec/lib/open_project/scm/manager_spec.rb @@ -28,21 +28,28 @@ #++ require 'spec_helper' -require 'workers/mail_notification_jobs/shared_examples' -describe DeliverWorkPackageCreatedJob, type: :model do - let(:work_package) { FactoryGirl.create :work_package, subject: mail_subject } - let(:job) { DeliverWorkPackageCreatedJob.new user.id, work_package.id, user.id } +describe OpenProject::Scm::Manager do + let(:vendor) { 'TestScm' } + let(:scm_class) { Class.new } - it_behaves_like 'a mail notification job' do - context 'with work package not found' do - let(:mail_subject) { 'no work package found! :o' } + before do + Repository.const_set(vendor, scm_class) + OpenProject::Scm::Manager.add vendor + end - before do - work_package.destroy - end + after do + Repository.send(:remove_const, vendor) + OpenProject::Scm::Manager.delete vendor + end - it_behaves_like 'job cannot find record' + it 'is a valid const' do + expect(OpenProject::Scm::Manager.registered[vendor]).to eq(Repository::TestScm) + end + + context 'scm is not known' do + it 'is not included' do + expect(OpenProject::Scm::Manager.registered).to_not have_key('SomeOtherScm') end end end diff --git a/spec/lib/open_project/text_formatting_spec.rb b/spec/lib/open_project/text_formatting_spec.rb index 4f5213c32d3..e6fd3e189ed 100644 --- a/spec/lib/open_project/text_formatting_spec.rb +++ b/spec/lib/open_project/text_formatting_spec.rb @@ -65,7 +65,7 @@ describe OpenProject::TextFormatting do context 'Changeset links' do let(:repository) do - FactoryGirl.build_stubbed :repository, + FactoryGirl.build_stubbed :repository_subversion, project: project end let(:changeset1) do @@ -431,7 +431,7 @@ describe OpenProject::TextFormatting do context 'Redmine links' do let(:repository) do - FactoryGirl.build_stubbed :repository, project: project + FactoryGirl.build_stubbed :repository_subversion, project: project end let(:source_url) do { controller: 'repositories', diff --git a/spec/lib/redmine/i18n_spec.rb b/spec/lib/redmine/i18n_spec.rb index c2132ec9b86..e729a86a9ca 100644 --- a/spec/lib/redmine/i18n_spec.rb +++ b/spec/lib/redmine/i18n_spec.rb @@ -130,21 +130,24 @@ module OpenProject end describe 'find_language' do - it 'is nil if language is not active' do + before do allow(Setting).to receive(:available_languages).and_return([:de]) + end + it 'is nil if language is not active' do expect(find_language(:en)).to be_nil end - it 'is the language if it is active' do - allow(Setting).to receive(:available_languages).and_return([:de]) + it 'is nil if no language is given' do + expect(find_language('')).to be_nil + expect(find_language(nil)).to be_nil + end + it 'is the language if it is active' do expect(find_language(:de)).to eql :de end it 'can be found by uppercase if it is active' do - allow(Setting).to receive(:available_languages).and_return([:de]) - expect(find_language(:DE)).to eql :de end end diff --git a/spec/lib/tabular_form_builder_spec.rb b/spec/lib/tabular_form_builder_spec.rb index 7d61a1fb51c..13bb2c697fc 100644 --- a/spec/lib/tabular_form_builder_spec.rb +++ b/spec/lib/tabular_form_builder_spec.rb @@ -77,6 +77,10 @@ describe TabularFormBuilder do it_behaves_like 'wrapped in field-container by default' it_behaves_like 'wrapped in container', 'text-field-container' + before do + allow(Setting).to receive(:available_languages).and_return([:en]) + end + it 'should output element' do expect(output).to be_html_eql(%{ no special behaviour required + + describe 'Journal 1' do + include_context 'updated until Journal 1' + + it_behaves_like 'enqueues a regular notification' + end + + describe 'Journal 2' do + include_context 'updated until Journal 2' + + it_behaves_like 'enqueues a regular notification' + end + + describe 'Journal 3' do + include_context 'updated until Journal 3' + + it_behaves_like 'enqueues a regular notification' + end + end + + context 'journal 3 created after timeout of 1, but inside of timeout for 2' do + describe 'Journal 3' do + include_context 'updated until Journal 3' + + before do + journal_2.update_attribute(:created_at, journal_1.created_at + (timeout / 2)) + journal_3.update_attribute(:created_at, journal_1.created_at + timeout + 5.seconds) + end + + it 'immediately delivers a mail on behalf of Journal 1' do + expect(Delayed::Job).to receive(:enqueue) + .with( + an_instance_of(DeliverWorkPackageNotificationJob)) + call_listener + end + + it 'also enqueues a regular mail' do + expect(Delayed::Job).to receive(:enqueue) + .with( + an_instance_of(EnqueueWorkPackageNotificationJob), + run_at: anything) + call_listener + end + end + end + + context 'journal 3 created after timeout of 1 and 2' do + # This is a normal case again, ensuring Journal 3 takes no responsiblity when not neccessary. + + describe 'Journal 3' do + include_context 'updated until Journal 3' + + before do + journal_2.update_attribute(:created_at, journal_1.created_at + (timeout / 2)) + journal_3.update_attribute(:created_at, journal_2.created_at + timeout + 5.seconds) + end + + it_behaves_like 'enqueues a regular notification' + end + end + + context 'two subsequent changes after timeout of another journal' do + # This is a normal case again, because handling edge cases makes us miss on the normal cases + + before do + work_package.journals.first.update_attribute(:created_at, (timeout + 5.seconds).ago) + work_package.reload + + expect(work_package.update_by!(author, { done_ratio: 50 })).to be_truthy + work_package.reload + expect(work_package.update_by!(author, { done_ratio: 60 })).to be_truthy + work_package.reload + end + + it_behaves_like 'enqueues a regular notification' + end + end +end + +describe 'initialization' do + it 'subscribes the listener' do + expect(JournalNotificationMailer).to receive(:distinguish_journals) + FactoryGirl.create(:work_package) + end +end diff --git a/spec/models/news_spec.rb b/spec/models/news_spec.rb index 772fad8aa93..3d793b09996 100644 --- a/spec/models/news_spec.rb +++ b/spec/models/news_spec.rb @@ -99,6 +99,8 @@ describe News, type: :model do user = FactoryGirl.create(:user) become_member_with_permissions(project, user) + # reload + project.members(true) with_settings notified_events: ['news_added'] do FactoryGirl.create(:news, project: project) diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 900b0f4cfa6..b298b92fee8 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -248,4 +248,59 @@ describe Project, type: :model do it_behaves_like 'respecting group assignment settings' end end + + describe 'countable required_project_storage' do + describe '#required_storage' do + it 'counts collections' do + expect(project).to receive(:count_for).with(:required_project_storage).and_call_original + storage_hash = project.required_storage + expect(storage_hash).to eq('attributes.attachments' => 0, total: 0) + end + + it 'counts only once before caching' do + expect(project) + .to receive(:count_for).with(:required_project_storage) + .once + .and_return([{ 'whatever' => 123, total: 1234 }, 'my label']) + + project.required_storage + project.required_storage + end + end + + describe '#total_projects_size' do + let(:projects) { FactoryGirl.build_list(:project, 3) } + before do + projects.each(&:save!) + allow(Project).to receive(:all).and_return(projects) + + allow(projects[0]).to receive(:count_for).and_return( + [{ 'attributes.attachments' => 23543, total: 23543 }, + 'unused project label'] + ) + allow(projects[1]).to receive(:count_for).and_return( + [{ 'attributes.attachments' => 2, label_repository: 2412345, total: 2412347 }, + 'unused project label'] + ) + Rails.cache.clear('projects/total_projects_size') + end + + it 'counts required_storage on all projects' do + expect(Project.all.length).to eq(3) + expect(Project.total_projects_size).to eq(2435890) + end + + it 'counts required_storage only once' do + expect(project).not_to receive(:count_for).with(:required_project_storage) + projects.each do |p| + expect(p).not_to receive(:count_for) + .with(:required_project_storage) + .once + .and_call_original + end + Project.total_projects_size + Project.total_projects_size + end + end + end end diff --git a/spec/models/repository/filesystem_spec.rb b/spec/models/repository/filesystem_spec.rb deleted file mode 100644 index 9fca58f1f13..00000000000 --- a/spec/models/repository/filesystem_spec.rb +++ /dev/null @@ -1,158 +0,0 @@ -#-- copyright -# OpenProject is a project management system. -# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See doc/COPYRIGHT.rdoc for more details. -#++ - -require 'spec_helper' - -describe Repository::Filesystem, type: :model do - before do - allow(Setting).to receive(:enabled_scm).and_return(['Filesystem']) - end - - let(:instance) { described_class.new } - - def mock_dirs_exist(input, output) - allow(Dir).to receive(:glob).with(input).and_return(output) - allow(Dir).to receive(:exists?).with(input).and_return(true) - end - - describe '.configured?' do - subject { described_class.configured? } - - context 'configuration contains directories' do - before do - allow(OpenProject::Configuration) - .to receive(:[]) - .with('scm_filesystem_path_whitelist') - .and_return(['/dir']) - end - - it 'is true' do - is_expected.to be_truthy - end - end - - context 'configuration does not contain directories' do - before do - allow(OpenProject::Configuration) - .to receive(:[]) - .with('scm_filesystem_path_whitelist') - .and_return([]) - end - - it 'is false' do - is_expected.to be_falsey - end - end - end - - describe '#valid?' do - let(:desired_url) { 'something' } - let(:whitelisted_urls) { 'another_thing' } - - let(:valid_args) do - { url: desired_url, - path_encoding: 'US-ASCII' } - end - let(:expected_url_whitelist_error_message) do - [I18n.t('activerecord.errors.models.repository.not_whitelisted')] - end - let(:expected_url_not_directory_error_message) do - [I18n.t('activerecord.errors.models.repository.no_directory')] - end - - before do - mock_dirs_exist(desired_url, ['/this/will/match']) - mock_dirs_exist(whitelisted_urls, ['/this/will/match']) - - allow(OpenProject::Configuration).to receive(:[]) - .with('scm_filesystem_path_whitelist') - .and_return(whitelisted_urls) - - instance.attributes = valid_args - end - - subject { instance } - - it 'is valid' do - is_expected.to be_valid - end - - context 'url not whitelisted' do - before do - mock_dirs_exist(desired_url, ['/desired/dir']) - mock_dirs_exist(whitelisted_urls, ['/desired', - '/desired/*', - '/desired/di', - '/desired/dir/1', - '*']) - end - - it 'is invalid' do - instance.attributes = valid_args - - is_expected.to be_invalid - expect(subject.errors[:url]).to eql(expected_url_whitelist_error_message) - end - end - - context 'url is not a directory' do - before do - allow(Dir).to receive(:exists?).with(desired_url).and_return(false) - end - - it 'is invalid' do - is_expected.to be_invalid - expect(subject.errors[:url]).to eql(expected_url_not_directory_error_message) - end - end - - context 'url does not exist' do - before do - mock_dirs_exist(desired_url, []) - end - - it 'is invalid' do - is_expected.to be_invalid - expect(subject.errors[:url]).to eql(expected_url_whitelist_error_message) - end - end - - context 'nothing is whitelisted' do - before do - mock_dirs_exist(whitelisted_urls, []) - end - - it 'is invalid' do - instance.attributes = valid_args - - is_expected.to be_invalid - expect(subject.errors[:url]).to eql(expected_url_whitelist_error_message) - end - end - end -end diff --git a/spec/models/repository/git_spec.rb b/spec/models/repository/git_spec.rb new file mode 100644 index 00000000000..361e208400f --- /dev/null +++ b/spec/models/repository/git_spec.rb @@ -0,0 +1,376 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe Repository::Git, type: :model do + let(:encoding) { 'UTF-8' } + let(:instance) { FactoryGirl.build(:repository_git, path_encoding: encoding) } + let(:adapter) { instance.scm } + let(:config) { {} } + + before do + allow(Setting).to receive(:enabled_scm).and_return(['Git']) + allow(instance).to receive(:scm).and_return(adapter) + allow(adapter.class).to receive(:config).and_return(config) + end + + describe 'available types' do + it 'allow local by default' do + expect(instance.class.available_types).to eq([:local]) + end + + context 'with disabled typed' do + let(:config) { { disabled_types: [:local, :managed] } } + + it 'does not have any types' do + expect(instance.class.available_types).to be_empty + end + end + end + + describe 'managed git' do + let(:managed_path) { '/tmp/managed_git' } + it 'is not manageable unless configured explicitly' do + expect(instance.manageable?).to be false + end + + context 'with managed config' do + let(:config) { { manages: managed_path } } + let(:project) { FactoryGirl.build :project } + let(:identifier) { project.identifier + '.git' } + + it 'is manageable' do + expect(instance.manageable?).to be true + expect(instance.class.available_types).to eq([:local, :managed]) + end + + context 'with disabled managed typed' do + let(:config) { { disabled_types: [:managed] } } + + it 'is no longer manageable' do + expect(instance.class.available_types).to eq([:local]) + expect(instance.manageable?).to be false + end + end + + context 'and associated project' do + before do + instance.project = project + end + + it 'outputs valid managed paths' do + expect(instance.repository_identifier).to eq(identifier) + path = File.join(managed_path, identifier) + expect(instance.managed_repository_path).to eq(path) + expect(instance.managed_repository_url).to eq(path) + end + end + + context 'and associated project with parent' do + let(:parent) { FactoryGirl.build :project } + let(:project) { FactoryGirl.build :project, parent: parent } + + before do + instance.project = project + end + + it 'outputs the correct hierarchy path' do + expect(instance.managed_repository_path) + .to eq(File.join(managed_path, identifier)) + end + end + end + end + + describe 'with an actual repository' do + with_git_repository do |repo_dir| + let(:url) { repo_dir } + let(:instance) { + FactoryGirl.create(:repository_git, + path_encoding: encoding, + url: url, + root_url: url) + } + + before do + instance.fetch_changesets + instance.reload + end + + it 'should be available' do + expect(instance.scm).to be_available + end + + it 'should fetch changesets from scratch' do + expect(instance.changesets.count).to eq(22) + expect(instance.changes.count).to eq(34) + + commit = instance.changesets.reorder('committed_on ASC').first + expect(commit.comments).to eq("Initial import.\nThe repository contains 3 files.") + expect(commit.committer).to eq('jsmith ') + # assert_equal User.find_by_login('jsmith'), commit.user + # TODO: add a commit with commit time <> author time to the test repository + expect(commit.committed_on).to eq('2007-12-14 09:22:52') + expect(commit.commit_date).to eq('2007-12-14'.to_date) + expect(commit.revision).to eq('7234cb2750b63f47bff735edc50a1c0a433c2518') + expect(commit.scmid).to eq('7234cb2750b63f47bff735edc50a1c0a433c2518') + expect(commit.changes.count).to eq(3) + + change = commit.changes.sort_by(&:path).first + expect(change.path).to eq('README') + expect(change.action).to eq('A') + end + + it 'should fetch changesets incremental' do + # Remove the 3 latest changesets + instance.changesets.find(:all, order: 'committed_on DESC', limit: 8).each(&:destroy) + instance.reload + expect(instance.changesets.count).to eq(14) + + rev_a_commit = instance.changesets.order('committed_on DESC').first + expect(rev_a_commit.revision).to eq('ed5bb786bbda2dee66a2d50faf51429dbc043a7b') + expect(rev_a_commit.scmid).to eq('ed5bb786bbda2dee66a2d50faf51429dbc043a7b') + # Mon Jul 5 22:34:26 2010 +0200 + committed_on = Time.gm(2010, 9, 18, 19, 59, 46) + expect(rev_a_commit.committed_on).to eq(committed_on) + expect(instance.latest_changeset.committed_on).to eq(committed_on) + + instance.fetch_changesets + expect(instance.changesets.count).to eq(22) + end + + describe '.latest_changesets' do + it 'should fetch changesets with limits' do + changesets = instance.latest_changesets('', nil, 2) + expect(changesets.size).to eq(2) + end + + it 'should fetch changesets with paths' do + changesets = instance.latest_changesets('images', nil) + expect(changesets.map(&:revision)) + .to eq(['deff712f05a90d96edbd70facc47d944be5897e3', + '899a15dba03a3b350b89c3f537e4bbe02a03cdc9', + '7234cb2750b63f47bff735edc50a1c0a433c2518']) + + changesets = instance.latest_changesets('README', nil) + expect(changesets.map(&:revision)) + .to eq(['32ae898b720c2f7eec2723d5bdd558b4cb2d3ddf', + '4a07fe31bffcf2888791f3e6cbc9c4545cefe3e8', + '713f4944648826f558cf548222f813dabe7cbb04', + '61b685fbe55ab05b5ac68402d5720c1a6ac973d1', + '899a15dba03a3b350b89c3f537e4bbe02a03cdc9', + '7234cb2750b63f47bff735edc50a1c0a433c2518']) + end + + it 'should fetch changesets with path, revision and limit' do + changesets = instance.latest_changesets('images', '899a15dba') + expect(changesets.map(&:revision)) + .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9', + '7234cb2750b63f47bff735edc50a1c0a433c2518']) + + changesets = instance.latest_changesets('images', '899a15dba', 1) + expect(changesets.map(&:revision)) + .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9']) + + changesets = instance.latest_changesets('README', '899a15dba') + expect(changesets.map(&:revision)) + .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9', + '7234cb2750b63f47bff735edc50a1c0a433c2518']) + + changesets = instance.latest_changesets('README', '899a15dba', 1) + expect(changesets.map(&:revision)) + .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9']) + end + + it 'should fetch changesets with tag' do + changesets = instance.latest_changesets('images', 'tag01.annotated') + expect(changesets.map(&:revision)) + .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9', + '7234cb2750b63f47bff735edc50a1c0a433c2518']) + + changesets = instance.latest_changesets('README', '899a15dba', 1) + expect(changesets.map(&:revision)) + .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9']) + + changesets = instance.latest_changesets('README', 'tag01.annotated') + expect(changesets.map(&:revision)) + .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9', + '7234cb2750b63f47bff735edc50a1c0a433c2518']) + + changesets = instance.latest_changesets('README', 'tag01.annotated', 1) + expect(changesets.map(&:revision)) + .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9']) + end + + it 'should fetch changesets with path, branch, and limit' do + changesets = instance.latest_changesets('images', 'test_branch') + expect(changesets.map(&:revision)) + .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9', + '7234cb2750b63f47bff735edc50a1c0a433c2518']) + + changesets = instance.latest_changesets('images', 'test_branch', 1) + expect(changesets.map(&:revision)) + .to eq(['899a15dba03a3b350b89c3f537e4bbe02a03cdc9']) + + changesets = instance.latest_changesets('README', 'test_branch') + expect(changesets.map(&:revision)) + .to eq(['713f4944648826f558cf548222f813dabe7cbb04', + '61b685fbe55ab05b5ac68402d5720c1a6ac973d1', + '899a15dba03a3b350b89c3f537e4bbe02a03cdc9', + '7234cb2750b63f47bff735edc50a1c0a433c2518']) + + changesets = instance.latest_changesets('README', 'test_branch', 2) + expect(changesets.map(&:revision)) + .to eq(['713f4944648826f558cf548222f813dabe7cbb04', + '61b685fbe55ab05b5ac68402d5720c1a6ac973d1']) + end + end + + it 'should find changeset by name' do + ['7234cb2750b63f47bff735edc50a1c0a433c2518', '7234cb2750b'].each do |r| + expect(instance.find_changeset_by_name(r).revision) + .to eq('7234cb2750b63f47bff735edc50a1c0a433c2518') + end + end + + it 'should find changeset by empty name' do + ['', ' ', nil].each do |r| + expect(instance.find_changeset_by_name(r)).to be_nil + end + end + + it 'should assign scmid to identifier' do + c = instance.changesets.where(revision: '7234cb2750b63f47bff735edc50a1c0a433c2518').first + expect(c.scmid).to eq(c.identifier) + end + + it 'should format identifier' do + c = instance.changesets.where(revision: '7234cb2750b63f47bff735edc50a1c0a433c2518').first + expect(c.format_identifier).to eq('7234cb27') + end + + it 'should find previous changeset' do + %w|1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127 1ca7f5ed|.each do |r1| + changeset = instance.find_changeset_by_name(r1) + %w|64f1f3e89ad1cb57976ff0ad99a107012ba3481d 64f1f3e89ad1|.each do |r2| + expect(instance.find_changeset_by_name(r2)).to eq(changeset.previous) + end + end + end + + it 'should return nil when no previous changeset' do + %w|7234cb2750b63f47bff735edc50a1c0a433c2518 7234cb2|.each do |r1| + changeset = instance.find_changeset_by_name(r1) + expect(changeset.previous).to be_nil + end + end + + it 'should find next changeset' do + %w|64f1f3e89ad1cb57976ff0ad99a107012ba3481d 64f1f3e89ad1|.each do |r2| + changeset = instance.find_changeset_by_name(r2) + %w|1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127 1ca7f5ed|.each do |r1| + expect(instance.find_changeset_by_name(r1)).to eq(changeset.next) + end + end + end + + it 'should next nil' do + %w|71e5c1d3dca6304805b143b9d0e6695fb3895ea4 71e5c1d3|.each do |r1| + changeset = instance.find_changeset_by_name(r1) + expect(changeset.next).to be_nil + end + end + + context 'with an admin browsing activity' do + let(:user) { FactoryGirl.create(:admin) } + let(:project) { FactoryGirl.create(:project) } + + def find_events(user, options = {}) + fetcher = Redmine::Activity::Fetcher.new(user, options) + fetcher.scope = ['changesets'] + fetcher.events(Date.today - 30, Date.today + 1) + end + + it 'should activities' do + Changeset.create(repository: instance, + committed_on: Time.now, + revision: 'abc7234cb2750b63f47bff735edc50a1c0a433c2', + scmid: 'abc7234cb2750b63f47bff735edc50a1c0a433c2', + comments: 'test') + + event = find_events(user).first + assert event.event_title.include?('abc7234c:') + assert event.event_path =~ /\?rev=abc7234cb2750b63f47bff735edc50a1c0a433c2$/ + end + end + + describe 'encoding' do + let(:felix_hex) { "Felix Sch\xC3\xA4fer" } + + it 'should display UTF-8' do + c = instance.changesets.where(revision: 'ed5bb786bbda2dee66a2d50faf51429dbc043a7b').first + expect(c.committer).to eq("#{felix_hex} ") + expect(c.committer).to eq('Felix Schäfer ') + end + + context 'with latin-1 encoding' do + let (:encoding) { 'ISO-8859-1' } + let (:char1_hex) { "\xc3\x9c".force_encoding('UTF-8') } + + it 'should latest changesets latin 1 dir' do + instance.fetch_changesets + instance.reload + changesets = instance.latest_changesets( + "latin-1-dir/test-#{char1_hex}-subdir", '1ca7f5ed') + expect(changesets.map(&:revision)) + .to eq(['1ca7f5ed374f3cb31a93ae5215c2e25cc6ec5127']) + end + + it 'should browse changesets' do + changesets = instance.latest_changesets( + "latin-1-dir/test-#{char1_hex}-2.txt", '64f1f3e89') + expect(changesets.map(&:revision)) + .to eq(['64f1f3e89ad1cb57976ff0ad99a107012ba3481d', + '4fc55c43bf3d3dc2efb66145365ddc17639ce81e', + ]) + + changesets = instance.latest_changesets( + "latin-1-dir/test-#{char1_hex}-2.txt", '64f1f3e89', 1) + expect(changesets.map(&:revision)) + .to eq(['64f1f3e89ad1cb57976ff0ad99a107012ba3481d']) + end + end + end + + it_behaves_like 'is a countable repository' do + let(:repository) { instance } + end + end + end +end diff --git a/spec/models/repository/subversion_spec.rb b/spec/models/repository/subversion_spec.rb new file mode 100644 index 00000000000..82de6da3fba --- /dev/null +++ b/spec/models/repository/subversion_spec.rb @@ -0,0 +1,301 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe Repository::Subversion, type: :model do + let(:instance) { FactoryGirl.build(:repository_subversion) } + let(:adapter) { instance.scm } + let(:config) { {} } + + before do + allow(Setting).to receive(:enabled_scm).and_return(['Subversion']) + allow(instance).to receive(:scm).and_return(adapter) + allow(instance.class).to receive(:scm_config).and_return(config) + end + + describe 'default Subversion' do + it 'is not manageable' do + expect(instance.manageable?).to be false + end + + it 'has one available type' do + expect(instance.class.available_types).to eq [:existing] + end + + context 'with disabled typed' do + let(:config) { { disabled_types: [:existing, :managed] } } + + it 'does not have any types' do + expect(instance.class.available_types).to be_empty + end + end + end + + describe 'managed Subversion' do + let(:managed_path) { '/tmp/managed_svn' } + it 'is not manageable unless configured explicitly' do + expect(instance.manageable?).to be false + end + + context 'with managed config' do + let(:config) { { manages: managed_path } } + let(:project) { FactoryGirl.build :project } + + it 'is manageable' do + expect(instance.manageable?).to be true + expect(instance.class.available_types).to eq([:existing, :managed]) + end + + context 'with disabled managed typed' do + let(:config) { { disabled_types: [:managed] } } + + it 'is no longer manageable' do + expect(instance.class.available_types).to eq([:existing]) + expect(instance.manageable?).to be false + end + end + + context 'and associated project' do + before do + instance.project = project + end + + it 'outputs valid managed paths' do + path = File.join(managed_path, project.identifier) + expect(instance.managed_repository_path).to eq(path) + expect(instance.managed_repository_url).to eq("file://#{path}") + end + end + + context 'and associated project with parent' do + let(:parent) { FactoryGirl.build :project } + let(:project) { FactoryGirl.build :project, parent: parent } + + before do + instance.project = project + end + + it 'outputs the correct hierarchy path' do + expect(instance.managed_repository_path) + .to eq(File.join(managed_path, project.identifier)) + end + end + end + end + + describe 'with a remote repository' do + let(:instance) { + FactoryGirl.build(:repository_subversion, + url: 'https://somewhere.example.org/svn/foo' + ) + } + + it_behaves_like 'is not a countable repository' do + let(:repository) { instance } + end + end + + describe 'with an actual repository' do + with_subversion_repository do |repo_dir| + let(:url) { "file://#{repo_dir}" } + let(:instance) { FactoryGirl.create(:repository_subversion, url: url, root_url: url) } + + it 'should be available' do + expect(instance.scm).to be_available + end + + it 'should fetch changesets from scratch' do + instance.fetch_changesets + instance.reload + + expect(instance.changesets.count).to eq(12) + expect(instance.changes.count).to eq(21) + expect(instance.changesets.find_by_revision('1').comments).to eq('Initial import.') + end + + it 'should fetch changesets incremental' do + instance.fetch_changesets + + # Remove changesets with revision > 5 + instance.changesets.find(:all).each do |c| c.destroy if c.revision.to_i > 5 end + instance.reload + expect(instance.changesets.count).to eq(5) + + instance.fetch_changesets + expect(instance.changesets.count).to eq(12) + end + + it 'should latest changesets' do + instance.fetch_changesets + + # with limit + changesets = instance.latest_changesets('', nil, 2) + assert_equal 2, changesets.size + assert_equal instance.latest_changesets('', nil).slice(0, 2), changesets + + # with path + changesets = instance.latest_changesets('subversion_test/folder', nil) + expect(changesets.map(&:revision)).to eq %w[10 9 7 6 5 2] + + # with path and revision + changesets = instance.latest_changesets('subversion_test/folder', 8) + expect(changesets.map(&:revision)).to eq %w[7 6 5 2] + end + + it 'should directory listing with square brackets in path' do + instance.fetch_changesets + instance.reload + + entries = instance.entries('subversion_test/[folder_with_brackets]') + expect(entries).to_not be_nil + expect(entries.size).to eq(1) + expect(entries.first.name).to eq('README.txt') + end + + context 'with square brackets in base' do + let(:url) { "file://#{repo_dir}/subversion_test/[folder_with_brackets]" } + + it 'should directory listing with square brackets in base' do + instance.fetch_changesets + instance.reload + + expect(instance.changesets.count).to eq(1) + expect(instance.changes.count).to eq(2) + + entries = instance.entries('') + expect(entries).to_not be_nil + expect(entries.size).to eq(1) + expect(entries.first.name).to eq('README.txt') + end + end + + it 'should show the identifier' do + instance.fetch_changesets + instance.reload + c = instance.changesets.find_by_revision('1') + expect(c.revision).to eq(c.identifier) + end + + it 'should find changeset by empty name' do + instance.fetch_changesets + instance.reload + ['', ' ', nil].each do |r| + expect(instance.find_changeset_by_name(r)).to be_nil + end + end + + it 'should identifier nine digit' do + c = Changeset.new(repository: instance, committed_on: Time.now, + revision: '123456789', comments: 'test') + expect(c.identifier).to eq(c.revision) + end + + it 'should format identifier' do + instance.fetch_changesets + instance.reload + c = instance.changesets.find_by_revision('1') + expect(c.format_identifier).to eq(c.revision) + end + + it 'should format identifier nine digit' do + c = Changeset.new(repository: instance, committed_on: Time.now, + revision: '123456789', comments: 'test') + expect(c.format_identifier).to eq(c.revision) + end + + it 'should log encoding ignore setting' do + with_settings commit_logs_encoding: 'windows-1252' do + s1 = "\xC2\x80" + s2 = "\xc3\x82\xc2\x80" + if s1.respond_to?(:force_encoding) + s1.force_encoding('ISO-8859-1') + s2.force_encoding('UTF-8') + assert_equal s1.encode('UTF-8'), s2 + end + c = Changeset.new(repository: instance, + comments: s2, + revision: '123', + committed_on: Time.now) + expect(c.save).to be true + expect(c.comments).to eq(s2) + end + end + + it 'should load previous and next changeset' do + instance.fetch_changesets + instance.reload + changeset2 = instance.find_changeset_by_name('2') + changeset3 = instance.find_changeset_by_name('3') + expect(changeset3.previous).to eq(changeset2) + expect(changeset2.next).to eq(changeset3) + end + + it 'should return nil for no previous or next changeset' do + instance.fetch_changesets + instance.reload + changeset = instance.find_changeset_by_name('1') + expect(changeset.previous).to be_nil + + changeset = instance.find_changeset_by_name('12') + expect(changeset.next).to be_nil + end + + context 'with an admin browsing activity' do + let(:user) { FactoryGirl.create(:admin) } + let(:project) { FactoryGirl.create(:project) } + + def find_events(user, options = {}) + fetcher = Redmine::Activity::Fetcher.new(user, options) + fetcher.scope = ['changesets'] + fetcher.events(Date.today - 30, Date.today + 1) + end + + it 'should find events' do + Changeset.create(repository: instance, committed_on: Time.now, + revision: '1', comments: 'test') + event = find_events(user).first + expect(event.event_title).to include('1:') + expect(event.event_path).to match(/\?rev=1$/) + end + + it 'should find events with larger numbers' do + Changeset.create(repository: instance, committed_on: Time.now, + revision: '123456789', comments: 'test') + event = find_events(user).first + expect(event.event_title).to include('123456789:') + expect(event.event_path).to match(/\?rev=123456789$/) + end + end + + it_behaves_like 'is a countable repository' do + let(:repository) { instance } + end + end + end +end diff --git a/spec/models/user_deletion_spec.rb b/spec/models/user_deletion_spec.rb index fb4c44f4573..8060ae14ffb 100644 --- a/spec/models/user_deletion_spec.rb +++ b/spec/models/user_deletion_spec.rb @@ -95,13 +95,13 @@ describe User, 'deletion', type: :model do it { expect(associated_instance.journals.first.user).to eq(user2) } it 'should update first journal changes' do associations.each do |association| - expect(associated_instance.journals.first.changed_data[association_key association].last).to eq(user2.id) + expect(associated_instance.journals.first.details[association_key association].last).to eq(user2.id) end end it { expect(associated_instance.journals.last.user).to eq(substitute_user) } it 'should update second journal changes' do associations.each do |association| - expect(associated_instance.journals.last.changed_data[association_key association].last).to eq(substitute_user.id) + expect(associated_instance.journals.last.details[association_key association].last).to eq(substitute_user.id) end end end @@ -157,14 +157,14 @@ describe User, 'deletion', type: :model do it { expect(associated_instance.journals.first.user).to eq(substitute_user) } it 'should update the first journal' do associations.each do |association| - expect(associated_instance.journals.first.changed_data[association_key association].last).to eq(substitute_user.id) + expect(associated_instance.journals.first.details[association_key association].last).to eq(substitute_user.id) end end it { expect(associated_instance.journals.last.user).to eq(user2) } it 'should update the last journal' do associations.each do |association| - expect(associated_instance.journals.last.changed_data[association_key association].first).to eq(substitute_user.id) - expect(associated_instance.journals.last.changed_data[association_key association].last).to eq(user2.id) + expect(associated_instance.journals.last.details[association_key association].first).to eq(substitute_user.id) + expect(associated_instance.journals.last.details[association_key association].last).to eq(user2.id) end end end @@ -233,13 +233,13 @@ describe User, 'deletion', type: :model do it { expect(associated_instance.journals.first.user).to eq(user2) } it 'should update first journal changes' do associations.each do |association| - expect(associated_instance.journals.first.changed_data[association_key association].last).to eq(user2.id) + expect(associated_instance.journals.first.details[association_key association].last).to eq(user2.id) end end it { expect(associated_instance.journals.last.user).to eq(substitute_user) } it 'should update second journal changes' do associations.each do |association| - expect(associated_instance.journals.last.changed_data[association_key association].last).to eq(substitute_user.id) + expect(associated_instance.journals.last.details[association_key association].last).to eq(substitute_user.id) end end end @@ -397,7 +397,7 @@ describe User, 'deletion', type: :model do end describe 'WHEN the user has created a changeset' do - with_created_filesystem_repository do + with_virtual_subversion_repository do let(:associated_instance) do FactoryGirl.build(:changeset, repository_id: repository.id, @@ -412,7 +412,7 @@ describe User, 'deletion', type: :model do end describe 'WHEN the user has updated a changeset' do - with_created_filesystem_repository do + with_virtual_subversion_repository do let(:associated_instance) do FactoryGirl.build(:changeset, repository_id: repository.id, @@ -443,11 +443,11 @@ describe User, 'deletion', type: :model do end it { expect(associated_instance.journals.first.user).to eq(user2) } it 'should update first journal changes' do - expect(associated_instance.journals.first.changed_data[:user_id].last).to eq(user2.id) + expect(associated_instance.journals.first.details[:user_id].last).to eq(user2.id) end it { expect(associated_instance.journals.last.user).to eq(substitute_user) } it 'should update second journal changes' do - expect(associated_instance.journals.last.changed_data[:user_id].last).to eq(substitute_user.id) + expect(associated_instance.journals.last.details[:user_id].last).to eq(substitute_user.id) end end diff --git a/spec/models/work_package/work_package_action_mailer_spec.rb b/spec/models/work_package/work_package_action_mailer_spec.rb index 5e9f9d56f0f..4d0cad3c2e9 100644 --- a/spec/models/work_package/work_package_action_mailer_spec.rb +++ b/spec/models/work_package/work_package_action_mailer_spec.rb @@ -31,20 +31,23 @@ require 'spec_helper' describe WorkPackage, type: :model do describe ActionMailer::Base do let(:user_1) { - FactoryGirl.create(:user, - mail: 'dlopper@somenet.foo') + FactoryGirl.build(:user, + mail: 'dlopper@somenet.foo', + member_in_project: project) } let(:user_2) { - FactoryGirl.create(:user, - mail: 'jsmith@somenet.foo') + FactoryGirl.build(:user, + mail: 'jsmith@somenet.foo', + member_in_project: project) } - let(:work_package) { FactoryGirl.build(:work_package) } + let(:project) { FactoryGirl.create(:project) } + let(:work_package) { FactoryGirl.build(:work_package, project: project) } before do ActionMailer::Base.deliveries.clear - allow(work_package).to receive(:recipients).and_return([user_1.mail]) - allow(work_package).to receive(:watcher_recipients).and_return([user_2.mail]) + allow(work_package).to receive(:recipients).and_return([user_1]) + allow(work_package).to receive(:watcher_recipients).and_return([user_2]) work_package.save end @@ -73,7 +76,7 @@ describe WorkPackage, type: :model do before do ActionMailer::Base.deliveries.clear - WorkPackageObserver.instance.send_notification = false + JournalManager.send_notification = false work_package.save! end @@ -91,7 +94,7 @@ describe WorkPackage, type: :model do subject { work_package.recipients } - it { is_expected.to include(user_1.mail) } + it { is_expected.to include(user_1) } end end end diff --git a/spec/models/work_package/work_package_acts_as_journalized_spec.rb b/spec/models/work_package/work_package_acts_as_journalized_spec.rb index 546a6925ff0..b9e67bb1cd4 100644 --- a/spec/models/work_package/work_package_acts_as_journalized_spec.rb +++ b/spec/models/work_package/work_package_acts_as_journalized_spec.rb @@ -162,7 +162,7 @@ describe WorkPackage, type: :model do end context 'last created journal' do - subject { work_package.journals.last.changed_data } + subject { work_package.journals.last.details } it 'contains all changes' do [:subject, :description, :type_id, :status_id, :priority_id, @@ -232,7 +232,7 @@ describe WorkPackage, type: :model do end context 'new attachment' do - subject { work_package.journals.last.changed_data } + subject { work_package.journals.last.details } it { is_expected.to have_key attachment_id } @@ -254,7 +254,7 @@ describe WorkPackage, type: :model do context 'attachment removed' do before do work_package.attachments.delete(attachment) end - subject { work_package.journals.last.changed_data } + subject { work_package.journals.last.details } it { is_expected.to have_key attachment_id } @@ -285,7 +285,7 @@ describe WorkPackage, type: :model do context 'new custom value' do include_context 'work package with custom value' - subject { work_package.journals.last.changed_data } + subject { work_package.journals.last.details } it { is_expected.to have_key custom_field_id } @@ -305,7 +305,7 @@ describe WorkPackage, type: :model do work_package.save! end - subject { work_package.journals.last.changed_data } + subject { work_package.journals.last.details } it { is_expected.to have_key custom_field_id } @@ -340,7 +340,7 @@ describe WorkPackage, type: :model do work_package.save! end - subject { work_package.journals.last.changed_data } + subject { work_package.journals.last.details } it { is_expected.to have_key custom_field_id } diff --git a/spec/models/work_package/work_package_copy_spec.rb b/spec/models/work_package/work_package_copy_spec.rb deleted file mode 100644 index 80fda7ce2d0..00000000000 --- a/spec/models/work_package/work_package_copy_spec.rb +++ /dev/null @@ -1,360 +0,0 @@ -#-- copyright -# OpenProject is a project management system. -# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See doc/COPYRIGHT.rdoc for more details. -#++ - -require 'spec_helper' - -describe WorkPackage, type: :model do - describe '#copy' do - let(:user) { FactoryGirl.create(:user) } - let(:custom_field) { FactoryGirl.create(:work_package_custom_field) } - let(:source_type) { - FactoryGirl.create(:type, - custom_fields: [custom_field]) - } - let(:source_project) { - FactoryGirl.create(:project, - types: [source_type]) - } - let(:work_package) { - FactoryGirl.create(:work_package, - project: source_project, - type: source_type, - author: user) - } - let(:custom_value) { - FactoryGirl.create(:work_package_custom_value, - custom_field: custom_field, - customized: work_package, - value: false) - } - - shared_examples_for 'copied work package' do - subject { copy.id } - - it { is_expected.not_to eq(work_package.id) } - end - - describe 'to the same project' do - let(:copy) { work_package.move_to_project(source_project, nil, copy: true) } - - it_behaves_like 'copied work package' - - context 'project' do - subject { copy.project } - - it { is_expected.to eq(source_project) } - end - end - - describe 'to a different project' do - let(:target_type) { FactoryGirl.create(:type) } - let(:target_project) { - FactoryGirl.create(:project, - types: [target_type]) - } - let(:copy) { work_package.move_to_project(target_project, target_type, copy: true) } - - it_behaves_like 'copied work package' - - context 'project' do - subject { copy.project_id } - - it { is_expected.to eq(target_project.id) } - end - - context 'type' do - subject { copy.type_id } - - it { is_expected.to eq(target_type.id) } - end - - context 'custom_fields' do - before do custom_value end - - subject { copy.custom_value_for(custom_field.id) } - - it { is_expected.to be_nil } - end - - describe '#attributes' do - let(:copy) { - work_package.move_to_project(target_project, - target_type, - copy: true, - attributes: attributes) - } - - context 'assigned_to' do - let(:target_user) { FactoryGirl.create(:user) } - let(:target_project_member) { - FactoryGirl.create(:member, - project: target_project, - principal: target_user, - roles: [FactoryGirl.create(:role)]) - } - let(:attributes) { { assigned_to_id: target_user.id } } - - before do target_project_member end - - it_behaves_like 'copied work package' - - subject { copy.assigned_to_id } - - it { is_expected.to eq(target_user.id) } - end - - context 'status' do - let(:target_status) { FactoryGirl.create(:status) } - let(:attributes) { { status_id: target_status.id } } - - it_behaves_like 'copied work package' - - subject { copy.status_id } - - it { is_expected.to eq(target_status.id) } - end - - context 'date' do - let(:target_date) { Date.today + 14 } - - context 'start' do - let(:attributes) { { start_date: target_date } } - - it_behaves_like 'copied work package' - - subject { copy.start_date } - - it { is_expected.to eq(target_date) } - end - - context 'end' do - let(:attributes) { { due_date: target_date } } - - it_behaves_like 'copied work package' - - subject { copy.due_date } - - it { is_expected.to eq(target_date) } - end - end - end - - describe 'private project' do - let(:role) { - FactoryGirl.create(:role, - permissions: [:view_work_packages]) - } - let(:target_project) { - FactoryGirl.create(:project, - is_public: false, - types: [target_type]) - } - let(:source_project_member) { - FactoryGirl.create(:member, - project: source_project, - principal: user, - roles: [role]) - } - - before do - source_project_member - allow(User).to receive(:current).and_return user - end - - it_behaves_like 'copied work package' - - context 'pre-condition' do - subject { work_package.recipients } - - it { is_expected.to include(work_package.author.mail) } - end - - subject { copy.recipients } - - it { is_expected.not_to include(copy.author.mail) } - end - - describe 'with children' do - let(:target_project) { FactoryGirl.create(:project, types: [source_type]) } - let(:copy) { child.reload.move_to_project(target_project) } - let!(:child) { - FactoryGirl.create(:work_package, parent: work_package, project: source_project) - } - let!(:grandchild) { - FactoryGirl.create(:work_package, parent: child, project: source_project) - } - - context 'cross project relations deactivated' do - before do - allow(Setting).to receive(:cross_project_work_package_relations?).and_return(false) - end - - it { expect(copy).to be_falsy } - - it { expect(child.reload.project).to eql(source_project) } - - describe 'grandchild' do - before do copy end - - it { expect(grandchild.reload.project).to eql(source_project) } - end - end - - context 'cross project relations activated' do - before do - allow(Setting).to receive(:cross_project_work_package_relations?).and_return(true) - end - - it { expect(copy).to be_truthy } - - it { expect(copy.project).to eql(target_project) } - - describe 'grandchild' do - before do copy end - - it { expect(grandchild.reload.project).to eql(target_project) } - end - end - end - end - end - - shared_context 'project with required custom field' do - before do - project.work_package_custom_fields << custom_field - type.custom_fields << custom_field - - source.save - end - end - - before do - def self.change_custom_field_value(work_package, value) - work_package.custom_field_values = { custom_field.id => value } unless value.nil? - work_package.save - end - end - - let(:type) { FactoryGirl.create(:type_standard) } - let(:project) { FactoryGirl.create(:project, types: [type]) } - let(:custom_field) { - FactoryGirl.create(:work_package_custom_field, - name: 'Database', - field_format: 'list', - possible_values: ['MySQL', 'PostgreSQL', 'Oracle'], - is_required: true) - } - - describe '#copy_from' do - include_context 'project with required custom field' - - let(:source) { FactoryGirl.build(:work_package) } - let(:sink) { FactoryGirl.build(:work_package) } - - before do - source.project_id = project.id - change_custom_field_value(source, 'MySQL') - end - - shared_examples_for 'work package copy' do - context 'subject' do - subject { sink.subject } - - it { is_expected.to eq(source.subject) } - end - - context 'type' do - subject { sink.type } - - it { is_expected.to eq(source.type) } - end - - context 'status' do - subject { sink.status } - - it { is_expected.to eq(source.status) } - end - - context 'project' do - subject { sink.project_id } - - it { is_expected.to eq(project_id) } - end - - context 'watchers' do - subject { sink.watchers.map(&:user_id) } - - it do - is_expected.to match_array(source.watchers.map(&:user_id)) - sink.watchers.each { |w| expect(w).to be_valid } - end - end - end - - shared_examples_for 'work package copy with custom field' do - it_behaves_like 'work package copy' - - context 'custom_field' do - subject { sink.custom_value_for(custom_field.id).value } - - it { is_expected.to eq('MySQL') } - end - end - - context 'with project' do - let(:project_id) { source.project_id } - - describe 'should copy project' do - before do sink.copy_from(source) end - - it_behaves_like 'work package copy with custom field' - end - - describe 'should not copy excluded project' do - let(:project_id) { sink.project_id } - - before do sink.copy_from(source, exclude: [:project_id]) end - - it_behaves_like 'work package copy' - end - - describe 'should copy over watchers' do - let(:project_id) { sink.project_id } - let(:stub_user) { FactoryGirl.create(:user, member_in_project: project) } - - before do - source.watchers.build(user: stub_user, watchable: source) - - sink.copy_from(source) - end - - it_behaves_like 'work package copy' - end - end - end -end diff --git a/spec/models/work_package/work_package_planning_spec.rb b/spec/models/work_package/work_package_planning_spec.rb index bc3b38243cb..554b8354297 100644 --- a/spec/models/work_package/work_package_planning_spec.rb +++ b/spec/models/work_package/work_package_planning_spec.rb @@ -249,7 +249,7 @@ describe WorkPackage, type: :model do it "has an initial journal, so that it's creation shows up in activity" do expect(pe.journals.size).to eq(1) - changes = pe.journals.first.changed_data.to_hash + changes = pe.journals.first.details.to_hash expect(changes.size).to eq(11) @@ -271,7 +271,7 @@ describe WorkPackage, type: :model do pe.update_attribute(:due_date, Date.new(2012, 2, 1)) expect(pe.journals.size).to eq(2) - changes = pe.journals.last.changed_data + changes = pe.journals.last.details expect(changes.size).to eq(1) @@ -311,7 +311,7 @@ describe WorkPackage, type: :model do pe.reload expect(pe.journals.size).to eq(3) - changes = pe.journals.last.changed_data + changes = pe.journals.last.details expect(changes.size).to eq(1) expect(changes).to include(:start_date) diff --git a/spec/models/work_package_spec.rb b/spec/models/work_package_spec.rb index 1843217af5e..f5d5489ab4f 100644 --- a/spec/models/work_package_spec.rb +++ b/spec/models/work_package_spec.rb @@ -481,170 +481,114 @@ describe WorkPackage, type: :model do end end - describe '#move' do - let(:work_package) { - FactoryGirl.create(:work_package, - project: project, - type: type) + describe '#copy_from' do + let(:type) { FactoryGirl.create(:type_standard) } + let(:project) { FactoryGirl.create(:project, types: [type]) } + let(:custom_field) { + FactoryGirl.create(:work_package_custom_field, + name: 'Database', + field_format: 'list', + possible_values: ['MySQL', 'PostgreSQL', 'Oracle'], + is_required: true) } - let(:target_project) { FactoryGirl.create(:project) } - shared_examples_for 'moved work package' do - subject { work_package.project } + let(:source) { FactoryGirl.build(:work_package) } + let(:sink) { FactoryGirl.build(:work_package) } - it { is_expected.to eq(target_project) } + before do + def self.change_custom_field_value(work_package, value) + work_package.custom_field_values = { custom_field.id => value } unless value.nil? + work_package.save + end end - describe '#time_entries' do - let(:time_entry_1) { - FactoryGirl.create(:time_entry, - project: project, - work_package: work_package) - } - let(:time_entry_2) { - FactoryGirl.create(:time_entry, - project: project, - work_package: work_package) - } + before do + project.work_package_custom_fields << custom_field + type.custom_fields << custom_field - before do - time_entry_1 - time_entry_2 - - work_package.reload - work_package.move_to_project(target_project) - - time_entry_1.reload - time_entry_2.reload - end - - context 'time entry 1' do - subject { work_package.time_entries } - - it { is_expected.to include(time_entry_1) } - end - - context 'time entry 2' do - subject { work_package.time_entries } - - it { is_expected.to include(time_entry_2) } - end - - it_behaves_like 'moved work package' + source.save end - describe '#category' do - let(:category) { - FactoryGirl.create(:category, - project: project) - } + before do + source.project_id = project.id + change_custom_field_value(source, 'MySQL') + end - before do - work_package.category = category - work_package.save! + shared_examples_for 'work package copy' do + context 'subject' do + subject { sink.subject } - work_package.reload + it { is_expected.to eq(source.subject) } end - context 'with same category' do - let(:target_category) { - FactoryGirl.create(:category, - name: category.name, - project: target_project) - } + context 'type' do + subject { sink.type } + + it { is_expected.to eq(source.type) } + end + + context 'status' do + subject { sink.status } + + it { is_expected.to eq(source.status) } + end + + context 'project' do + subject { sink.project_id } + + it { is_expected.to eq(project_id) } + end + + context 'watchers' do + subject { sink.watchers.map(&:user_id) } + + it do + is_expected.to match_array(source.watchers.map(&:user_id)) + sink.watchers.each { |w| expect(w).to be_valid } + end + end + end + + shared_examples_for 'work package copy with custom field' do + it_behaves_like 'work package copy' + + context 'custom_field' do + subject { sink.custom_value_for(custom_field.id).value } + + it { is_expected.to eq('MySQL') } + end + end + + context 'with project' do + let(:project_id) { source.project_id } + + describe 'should copy project' do + + before { sink.copy_from(source) } + + it_behaves_like 'work package copy with custom field' + end + + describe 'should not copy excluded project' do + let(:project_id) { sink.project_id } + + before { sink.copy_from(source, exclude: [:project_id]) } + + it_behaves_like 'work package copy' + end + + describe 'should copy over watchers' do + let(:project_id) { sink.project_id } + let(:stub_user) { FactoryGirl.create(:user, member_in_project: project) } before do - target_category + source.watchers.build(user: stub_user, watchable: source) - work_package.move_to_project(target_project) + sink.copy_from(source) end - describe 'category moved' do - subject { work_package.category_id } - - it { is_expected.to eq(target_category.id) } - end - - it_behaves_like 'moved work package' + it_behaves_like 'work package copy' end - - context 'w/o target category' do - before do work_package.move_to_project(target_project) end - - describe 'category discarded' do - subject { work_package.category_id } - - it { is_expected.to be_nil } - end - - it_behaves_like 'moved work package' - end - end - - describe '#version' do - let(:sharing) { 'none' } - let(:version) { - FactoryGirl.create(:version, - status: 'open', - project: project, - sharing: sharing) - } - let(:work_package) { - FactoryGirl.create(:work_package, - fixed_version: version, - project: project) - } - - before do work_package.move_to_project(target_project) end - - it_behaves_like 'moved work package' - - context 'unshared version' do - subject { work_package.fixed_version } - - it { is_expected.to be_nil } - end - - context 'system wide shared version' do - let(:sharing) { 'system' } - - subject { work_package.fixed_version } - - it { is_expected.to eq(version) } - end - - context 'move work package in project hierarchy' do - let(:target_project) { - FactoryGirl.create(:project, - parent: project) - } - - context 'unshared version' do - subject { work_package.fixed_version } - - it { is_expected.to be_nil } - end - - context 'shared version' do - let(:sharing) { 'tree' } - - subject { work_package.fixed_version } - - it { is_expected.to eq(version) } - end - end - end - - describe '#type' do - let(:target_type) { FactoryGirl.create(:type) } - let(:target_project) { - FactoryGirl.create(:project, - types: [target_type]) - } - - subject { work_package.move_to_project(target_project) } - - it { is_expected.to be_falsey } end end @@ -1037,7 +981,7 @@ describe WorkPackage, type: :model do it { is_expected.not_to be_nil } end - let(:expected_users) { work_package.author.mail } + let(:expected_users) { work_package.author } it_behaves_like 'includes expected users' end @@ -1051,7 +995,7 @@ describe WorkPackage, type: :model do it { is_expected.not_to be_nil } end - let(:expected_users) { work_package.assigned_to.mail } + let(:expected_users) { work_package.assigned_to } it_behaves_like 'includes expected users' end @@ -1333,36 +1277,104 @@ describe WorkPackage, type: :model do end describe '#allowed_target_projects_on_move' do - let(:admin_user) { FactoryGirl.create :admin } - let(:valid_user) { FactoryGirl.create :user } let(:project) { FactoryGirl.create :project } - context 'admin user' do - before do - allow(User).to receive(:current).and_return admin_user - project + subject { WorkPackage.allowed_target_projects_on_move(user) } + + before do + allow(User).to receive(:current).and_return user + project + end + + shared_examples_for 'has the permission to see projects' do + it 'sees the project' do + is_expected.to match_array [project] end - subject { WorkPackage.allowed_target_projects_on_move.count } + it 'does not see the archived project' do + project.update_attribute(:status, Project::STATUS_ARCHIVED) - it 'sees all active projects' do - is_expected.to eq Project.active.count + is_expected.to match_array [] + end + + it 'does not see the project having the work package module disabled' do + enabled_modules = project.enabled_module_names.delete(:work_package_tracking) + project.enabled_module_names = enabled_modules + project.save! + + is_expected.to match_array [] + end + end + + shared_examples_for 'lacks the permission to see projects' do + it 'does not see the project' do + is_expected.to match_array [] + end + end + + context 'admin user' do + let(:admin_user) { FactoryGirl.create :admin } + + it_behaves_like 'has the permission to see projects' do + let(:user) { admin_user } end end context 'non admin user' do - before do - allow(User).to receive(:current).and_return valid_user + let(:role) { FactoryGirl.build(:role, permissions: user_in_project_permissions) } + let(:user_in_project_permissions) { [:move_work_packages] } + let(:user_in_project) { + FactoryGirl.build :user, + member_in_project: project, + member_through_role: role + } - role = FactoryGirl.create :role, permissions: [:move_work_packages] + it_behaves_like 'has the permission to see projects' do + before do + user_in_project.save! + end - FactoryGirl.create(:member, user: valid_user, project: project, roles: [role]) + let(:user) { user_in_project } end - subject { WorkPackage.allowed_target_projects_on_move.count } + it_behaves_like 'lacks the permission to see projects' do + let(:user_in_project_permissions) { [] } - it 'sees all active projects' do - is_expected.to eq Project.active.count + before do + user_in_project.save! + end + + let(:user) { user_in_project } + end + end + + context 'non member user' do + it_behaves_like 'lacks the permission to see projects' do + before do + project.update_attribute(:is_public, true) + FactoryGirl.create(:non_member, permissions: []) + end + + let(:user) { FactoryGirl.create(:user) } + end + + it_behaves_like 'has the permission to see projects' do + before do + project.update_attribute(:is_public, true) + FactoryGirl.create(:non_member, permissions: [:move_work_packages]) + end + + let(:user) { FactoryGirl.create(:user) } + end + end + + context 'anonymous user' do + it_behaves_like 'lacks the permission to see projects' do + before do + project.update_attribute(:is_public, true) + end + + let(:user) { FactoryGirl.create(:anonymous) } end end end @@ -1491,20 +1503,20 @@ describe WorkPackage, type: :model do it 'should not include certain attributes' do recreated_journal = @issue.recreate_initial_journal! - expect(recreated_journal.changed_data.include?('rgt')).to eq(false) - expect(recreated_journal.changed_data.include?('lft')).to eq(false) - expect(recreated_journal.changed_data.include?('lock_version')).to eq(false) - expect(recreated_journal.changed_data.include?('updated_at')).to eq(false) - expect(recreated_journal.changed_data.include?('updated_on')).to eq(false) - expect(recreated_journal.changed_data.include?('id')).to eq(false) - expect(recreated_journal.changed_data.include?('type')).to eq(false) - expect(recreated_journal.changed_data.include?('root_id')).to eq(false) + expect(recreated_journal.details.include?('rgt')).to eq(false) + expect(recreated_journal.details.include?('lft')).to eq(false) + expect(recreated_journal.details.include?('lock_version')).to eq(false) + expect(recreated_journal.details.include?('updated_at')).to eq(false) + expect(recreated_journal.details.include?('updated_on')).to eq(false) + expect(recreated_journal.details.include?('id')).to eq(false) + expect(recreated_journal.details.include?('type')).to eq(false) + expect(recreated_journal.details.include?('root_id')).to eq(false) end it 'should not include useless transitions' do recreated_journal = @issue.recreate_initial_journal! - recreated_journal.changed_data.values.each do |change| + recreated_journal.details.values.each do |change| expect(change.first).not_to eq(change.last) end end diff --git a/spec/requests/api/v3/work_packages_api_spec.rb b/spec/requests/api/v3/activities_by_work_package_resource_spec.rb similarity index 97% rename from spec/requests/api/v3/work_packages_api_spec.rb rename to spec/requests/api/v3/activities_by_work_package_resource_spec.rb index 86435bdfc1d..cade61fdd3f 100644 --- a/spec/requests/api/v3/work_packages_api_spec.rb +++ b/spec/requests/api/v3/activities_by_work_package_resource_spec.rb @@ -29,7 +29,7 @@ require 'spec_helper' require 'rack/test' -describe API::V3::WorkPackages::WorkPackagesAPI, type: :request do +describe API::V3::Activities::ActivitiesByWorkPackageAPI, type: :request do include API::V3::Utilities::PathHelper let(:admin) { FactoryGirl.create(:admin) } diff --git a/spec/requests/api/v3/activity_resource_spec.rb b/spec/requests/api/v3/activity_resource_spec.rb index f9bb1eaadcc..7c12eae4049 100644 --- a/spec/requests/api/v3/activity_resource_spec.rb +++ b/spec/requests/api/v3/activity_resource_spec.rb @@ -33,11 +33,19 @@ describe 'API v3 Activity resource', type: :request do include Rack::Test::Methods include API::V3::Utilities::PathHelper - let(:current_user) { FactoryGirl.create(:user) } + let(:current_user) { + FactoryGirl.create(:user, member_in_project: project, member_through_role: role) + } let(:project) { FactoryGirl.create(:project, is_public: false) } let(:work_package) { FactoryGirl.create(:work_package, author: current_user, project: project) } - let(:role) { FactoryGirl.create(:role, permissions: [:view_work_packages]) } - let(:activity) { FactoryGirl.create(:work_package_journal, journable: work_package) } + let(:role) { FactoryGirl.create(:role, permissions: permissions) } + let(:permissions) { [:view_work_packages, :edit_work_package_notes] } + let(:activity) { work_package.journals.first } + + before do + allow(User).to receive(:current).and_return current_user + work_package.save! + end describe '#get' do subject(:response) { last_response } @@ -45,10 +53,6 @@ describe 'API v3 Activity resource', type: :request do context 'logged in user' do let(:get_path) { api_v3_paths.activity activity.id } before do - allow(User).to receive(:current).and_return current_user - member = FactoryGirl.build(:member, user: current_user, project: project) - member.role_ids = [role.id] - member.save! get get_path end @@ -80,9 +84,40 @@ describe 'API v3 Activity resource', type: :request do end end - it_behaves_like 'handling anonymous user', 'Activity', '/api/v3/activities/%s' do + it_behaves_like 'handling anonymous user' do let(:project) { FactoryGirl.create(:project, is_public: true) } - let(:id) { activity.id } + let(:path) { api_v3_paths.activity activity.id } + end + end + + describe '#patch' do + subject(:response) { last_response } + let(:patch_path) { api_v3_paths.activity activity.id } + let(:valid_params) { + { + comment: 'a fancy comment!' + } + } + + context 'authorized user' do + before do + patch patch_path, valid_params.to_json, 'CONTENT_TYPE' => 'application/json' + end + + subject(:response) { last_response } + + it 'should respond with HTTP OK' do + expect(subject.status).to eq(200) + end + + it 'changes the comment' do + activity.reload + expect(activity.notes).to eql 'a fancy comment!' + end + + it 'responds with the updated activity' do + expect(subject.body).to be_json_eql('a fancy comment!'.to_json).at_path('comment/raw') + end end end end diff --git a/spec/requests/api/v3/repositories/revisions_by_work_package_resource_spec.rb b/spec/requests/api/v3/repositories/revisions_by_work_package_resource_spec.rb new file mode 100644 index 00000000000..5e43af9ab2b --- /dev/null +++ b/spec/requests/api/v3/repositories/revisions_by_work_package_resource_spec.rb @@ -0,0 +1,98 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' +require 'rack/test' + +describe 'API v3 Revisions by work package resource', type: :request do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + include FileHelpers + + let(:current_user) { + FactoryGirl.create(:user, + member_in_project: project, + member_through_role: role) + } + let(:project) { FactoryGirl.create(:project, is_public: false) } + let(:role) { FactoryGirl.create(:role, permissions: permissions) } + let(:permissions) { [:view_work_packages, :view_changesets] } + let(:repository) { FactoryGirl.create(:repository_subversion, project: project) } + let(:work_package) { FactoryGirl.create(:work_package, author: current_user, project: project) } + let(:revisions) { [] } + + subject(:response) { last_response } + + before do + allow(User).to receive(:current).and_return current_user + end + + describe '#get' do + let(:get_path) { api_v3_paths.work_package_revisions work_package.id } + + before do + revisions.each do |rev| rev.save! end + get get_path + end + + it 'should respond with 200' do + expect(subject.status).to eq(200) + end + + it_behaves_like 'API V3 collection response', 0, 0, 'Revision' + + + context 'with existing revisions' do + let(:revisions) { + FactoryGirl.build_list(:changeset, + 5, + comments: "This commit references ##{work_package.id}", + repository: repository + ) + } + + it_behaves_like 'API V3 collection response', 5, 5, 'Revision' + end + + context 'user unauthorized to view work package' do + let(:current_user) { FactoryGirl.create(:user) } + + it 'should respond with 404' do + expect(subject.status).to eq(404) + end + end + + context 'user unauthorized to view revisions' do + let(:permissions) { [:view_work_packages] } + + it 'should respond with 403' do + expect(subject.status).to eq(403) + end + end + end +end diff --git a/spec/requests/api/v3/repositories/revisions_resource_spec.rb b/spec/requests/api/v3/repositories/revisions_resource_spec.rb new file mode 100644 index 00000000000..e0948814a11 --- /dev/null +++ b/spec/requests/api/v3/repositories/revisions_resource_spec.rb @@ -0,0 +1,97 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' +require 'rack/test' + +describe 'API v3 Revisions resource', type: :request do + include Rack::Test::Methods + include Capybara::RSpecMatchers + include API::V3::Utilities::PathHelper + + let(:revision) { + FactoryGirl.create(:changeset, + repository: repository, + comments: 'Some commit message', + committer: 'foo bar ' + ) + } + let(:repository) { + FactoryGirl.create(:repository_subversion, project: project) + } + let(:project) { + FactoryGirl.create(:project, identifier: 'test_project', is_public: false) + } + let(:role) { + FactoryGirl.create(:role, + permissions: [:view_changesets]) + } + let(:current_user) { + FactoryGirl.create(:user, member_in_project: project, member_through_role: role) + } + + let(:unauthorized_user) { FactoryGirl.create(:user) } + + describe '#get' do + let(:get_path) { api_v3_paths.revision revision.id } + + context 'when acting as a user with permission to view revisions' do + before(:each) do + allow(User).to receive(:current).and_return current_user + get get_path + end + + it 'should respond with 200' do + expect(last_response.status).to eq(200) + end + + describe 'response body' do + subject(:response) { last_response.body } + + it 'should respond with revision in HAL+JSON format' do + is_expected.to be_json_eql(revision.id.to_json).at_path('id') + end + end + + context 'requesting nonexistent revision' do + let(:get_path) { api_v3_paths.revision 909090 } + + it_behaves_like 'not found' + end + end + + context 'when acting as an user without permission to view work package' do + before(:each) do + allow(User).to receive(:current).and_return unauthorized_user + get get_path + end + + it_behaves_like 'not found' + end + end +end diff --git a/spec/requests/api/v3/support/authorization.rb b/spec/requests/api/v3/support/authorization.rb index 29f6906e753..d0fa4203af4 100644 --- a/spec/requests/api/v3/support/authorization.rb +++ b/spec/requests/api/v3/support/authorization.rb @@ -28,27 +28,16 @@ require 'spec_helper' -shared_examples_for 'handling anonymous user' do |type, path| +shared_examples_for 'handling anonymous user' do context 'anonymous user' do - let(:get_path) { path % [id] } - - context 'when access for anonymous user is allowed' do - before do get get_path end - - it 'should respond with 200' do - expect(subject.status).to eq(200) - end - - it 'should respond with correct type' do - expect(subject.body).to include_json(type.to_json).at_path('_type') - expect(subject.body).to be_json_eql(id.to_json).at_path('id') - end + before do + allow(User).to receive(:current).and_return(User.anonymous) end context 'when access for anonymous user is not allowed' do before do allow(Setting).to receive(:login_required?).and_return(true) - get get_path + get path end it_behaves_like 'unauthenticated access' diff --git a/spec/requests/api/v3/user_resource_spec.rb b/spec/requests/api/v3/user_resource_spec.rb index 22338dea80f..bdf115eabdc 100644 --- a/spec/requests/api/v3/user_resource_spec.rb +++ b/spec/requests/api/v3/user_resource_spec.rb @@ -66,8 +66,8 @@ describe 'API v3 User resource', type: :request do end end - it_behaves_like 'handling anonymous user', 'User', '/api/v3/users/%s' do - let(:id) { user.id } + it_behaves_like 'handling anonymous user' do + let(:path) { api_v3_paths.user user.id } end end diff --git a/spec/routing/repositories_routing_spec.rb b/spec/routing/repositories_routing_spec.rb index 23fcddb5e84..dc4b98dc034 100644 --- a/spec/routing/repositories_routing_spec.rb +++ b/spec/routing/repositories_routing_spec.rb @@ -31,219 +31,267 @@ require 'spec_helper' describe RepositoriesController, type: :routing do describe 'show' do it { - expect(get('/projects/testproject/repository')).to route_to(controller: 'repositories', - action: 'show', - project_id: 'testproject') + expect(get('/projects/testproject/repository')) + .to route_to(controller: 'repositories', + action: 'show', + project_id: 'testproject') } it { - expect(get('/projects/testproject/repository/path/to/file.c')).to route_to(controller: 'repositories', - action: 'show', - project_id: 'testproject', - path: 'path/to/file.c') + expect(get('/projects/testproject/repository/path/to/file.c')) + .to route_to(controller: 'repositories', + action: 'show', + project_id: 'testproject', + path: 'path/to/file.c') } it { - expect(get('/projects/testproject/repository/revisions/5')).to route_to(controller: 'repositories', - action: 'show', - rev: '5', - project_id: 'testproject') + expect(get('/projects/testproject/repository/revisions/5')) + .to route_to(controller: 'repositories', + action: 'show', + rev: '5', + project_id: 'testproject') } end describe 'edit' do it { - expect(get('/projects/testproject/repository/edit')).to route_to(controller: 'repositories', - action: 'edit', - project_id: 'testproject') + expect(get('/projects/testproject/repository/edit')) + .to route_to(controller: 'repositories', + action: 'edit', + project_id: 'testproject') } + end + describe 'create' do it { - expect(post('/projects/testproject/repository/edit')).to route_to(controller: 'repositories', - action: 'edit', - project_id: 'testproject') + expect(post('/projects/testproject/repository/')) + .to route_to(controller: 'repositories', + action: 'create', + project_id: 'testproject') + } + end + + describe 'update' do + it { + expect(put('/projects/testproject/repository/')) + .to route_to(controller: 'repositories', + action: 'update', + project_id: 'testproject') } end describe 'revisions' do it { - expect(get('/projects/testproject/repository/revisions')).to route_to(controller: 'repositories', - action: 'revisions', - project_id: 'testproject') + expect(get('/projects/testproject/repository/revisions')) + .to route_to(controller: 'repositories', + action: 'revisions', + project_id: 'testproject') } it { - expect(get('/projects/testproject/repository/revisions.atom')).to route_to(controller: 'repositories', - action: 'revisions', - project_id: 'testproject', - format: 'atom') + expect(get('/projects/testproject/repository/revisions.atom')) + .to route_to(controller: 'repositories', + action: 'revisions', + project_id: 'testproject', + format: 'atom') } end describe 'revision' do it { - expect(get('/projects/testproject/repository/revision/2457')).to route_to(controller: 'repositories', - action: 'revision', - project_id: 'testproject', - rev: '2457') + expect(get('/projects/testproject/repository/revision/2457')) + .to route_to(controller: 'repositories', + action: 'revision', + project_id: 'testproject', + rev: '2457') } it { - expect(get('/projects/testproject/repository/revision')).to route_to(controller: 'repositories', - action: 'revision', - project_id: 'testproject') + expect(get('/projects/testproject/repository/revision')) + .to route_to(controller: 'repositories', + action: 'revision', + project_id: 'testproject') } end describe 'diff' do it { - expect(get('/projects/testproject/repository/revisions/2457/diff')).to route_to(controller: 'repositories', - action: 'diff', - project_id: 'testproject', - rev: '2457') + expect(get('/projects/testproject/repository/revisions/2457/diff')) + .to route_to(controller: 'repositories', + action: 'diff', + project_id: 'testproject', + rev: '2457') } it { - expect(get('/projects/testproject/repository/revisions/2457/diff.diff')).to route_to(controller: 'repositories', - action: 'diff', - project_id: 'testproject', - rev: '2457', - format: 'diff') + expect(get('/projects/testproject/repository/revisions/2457/diff.diff')) + .to route_to(controller: 'repositories', + action: 'diff', + project_id: 'testproject', + rev: '2457', + format: 'diff') } it { - expect(get('/projects/testproject/repository/diff')).to route_to(controller: 'repositories', - action: 'diff', - project_id: 'testproject') + expect(get('/projects/testproject/repository/diff')) + .to route_to(controller: 'repositories', + action: 'diff', + project_id: 'testproject') } it { - expect(get('/projects/testproject/repository/diff/path/to/file.c')).to route_to(controller: 'repositories', - action: 'diff', - project_id: 'testproject', - path: 'path/to/file.c') + expect(get('/projects/testproject/repository/diff/path/to/file.c')) + .to route_to(controller: 'repositories', + action: 'diff', + project_id: 'testproject', + path: 'path/to/file.c') } it { - expect(get('/projects/testproject/repository/revisions/2/diff/path/to/file.c')).to route_to(controller: 'repositories', - action: 'diff', - project_id: 'testproject', - path: 'path/to/file.c', - rev: '2') + expect(get('/projects/testproject/repository/revisions/2/diff/path/to/file.c')) + .to route_to(controller: 'repositories', + action: 'diff', + project_id: 'testproject', + path: 'path/to/file.c', + rev: '2') } end describe 'browse' do it { - expect(get('/projects/testproject/repository/browse/path/to/file.c')).to route_to(controller: 'repositories', - action: 'browse', - project_id: 'testproject', - path: 'path/to/file.c') + expect(get('/projects/testproject/repository/browse/path/to/file.c')) + .to route_to(controller: 'repositories', + action: 'browse', + project_id: 'testproject', + path: 'path/to/file.c') } end describe 'entry' do it { - expect(get('/projects/testproject/repository/entry/path/to/file.c')).to route_to(controller: 'repositories', - action: 'entry', - project_id: 'testproject', - path: 'path/to/file.c') + expect(get('/projects/testproject/repository/entry/path/to/file.c')) + .to route_to(controller: 'repositories', + action: 'entry', + project_id: 'testproject', + path: 'path/to/file.c') } it { - expect(get('/projects/testproject/repository/revisions/2/entry/path/to/file.c')).to route_to(controller: 'repositories', - action: 'entry', - project_id: 'testproject', - path: 'path/to/file.c', - rev: '2') + expect(get('/projects/testproject/repository/revisions/2/entry/path/to/file.c')) + .to route_to(controller: 'repositories', + action: 'entry', + project_id: 'testproject', + path: 'path/to/file.c', + rev: '2') } it { - expect(get('/projects/testproject/repository/raw/path/to/file.c')).to route_to(controller: 'repositories', - action: 'entry', - project_id: 'testproject', - path: 'path/to/file.c', - format: 'raw') + expect(get('/projects/testproject/repository/raw/path/to/file.c')) + .to route_to(controller: 'repositories', + action: 'entry', + project_id: 'testproject', + path: 'path/to/file.c', + format: 'raw') } it { - expect(get('/projects/testproject/repository/revisions/master/raw/path/to/file.c')).to route_to(controller: 'repositories', - action: 'entry', - project_id: 'testproject', - path: 'path/to/file.c', - rev: 'master', - format: 'raw') + expect(get('/projects/testproject/repository/revisions/master/raw/path/to/file.c')) + .to route_to(controller: 'repositories', + action: 'entry', + project_id: 'testproject', + path: 'path/to/file.c', + rev: 'master', + format: 'raw') } end describe 'annotate' do it { - expect(get('/projects/testproject/repository/annotate/path/to/file.c')).to route_to(controller: 'repositories', - action: 'annotate', - project_id: 'testproject', - path: 'path/to/file.c') + expect(get('/projects/testproject/repository/annotate/path/to/file.c')) + .to route_to(controller: 'repositories', + action: 'annotate', + project_id: 'testproject', + path: 'path/to/file.c') } it { - expect(get('/projects/testproject/repository/revisions/5/annotate/path/to/file.c')).to route_to(controller: 'repositories', - action: 'annotate', - project_id: 'testproject', - path: 'path/to/file.c', - rev: '5') + expect(get('/projects/testproject/repository/revisions/5/annotate/path/to/file.c')) + .to route_to(controller: 'repositories', + action: 'annotate', + project_id: 'testproject', + path: 'path/to/file.c', + rev: '5') } end describe 'changes' do it { - expect(get('/projects/testproject/repository/changes/path/to/file.c')).to route_to(controller: 'repositories', - action: 'changes', - project_id: 'testproject', - path: 'path/to/file.c') + expect(get('/projects/testproject/repository/changes/path/to/file.c')) + .to route_to(controller: 'repositories', + action: 'changes', + project_id: 'testproject', + path: 'path/to/file.c') } it { - expect(get('/projects/testproject/repository/revisions/5/changes/path/to/file.c')).to route_to(controller: 'repositories', - action: 'changes', - project_id: 'testproject', - path: 'path/to/file.c', - rev: '5') + expect(get('/projects/testproject/repository/revisions/5/changes/path/to/file.c')) + .to route_to(controller: 'repositories', + action: 'changes', + project_id: 'testproject', + path: 'path/to/file.c', + rev: '5') } end describe 'stats' do it { - expect(get('/projects/testproject/repository/statistics')).to route_to(controller: 'repositories', - action: 'stats', - project_id: 'testproject') + expect(get('/projects/testproject/repository/statistics')) + .to route_to(controller: 'repositories', + action: 'stats', + project_id: 'testproject') } end describe 'committers' do it { - expect(get('/projects/testproject/repository/committers')).to route_to(controller: 'repositories', - action: 'committers', - project_id: 'testproject') + expect(get('/projects/testproject/repository/committers')) + .to route_to(controller: 'repositories', + action: 'committers', + project_id: 'testproject') } it { - expect(post('/projects/testproject/repository/committers')).to route_to(controller: 'repositories', - action: 'committers', - project_id: 'testproject') + expect(post('/projects/testproject/repository/committers')) + .to route_to(controller: 'repositories', + action: 'committers', + project_id: 'testproject') } end describe 'graph' do it { - expect(get('/projects/testproject/repository/graph')).to route_to(controller: 'repositories', - action: 'graph', - project_id: 'testproject') + expect(get('/projects/testproject/repository/graph')) + .to route_to(controller: 'repositories', + action: 'graph', + project_id: 'testproject') } end describe 'destroy' do it { - expect(delete('/projects/testproject/repository')).to route_to(controller: 'repositories', - action: 'destroy', - project_id: 'testproject') + expect(delete('/projects/testproject/repository')) + .to route_to(controller: 'repositories', + action: 'destroy', + project_id: 'testproject') + } + end + + describe 'destroy_info' do + it { + expect(get('/projects/testproject/repository/destroy_info')) + .to route_to(controller: 'repositories', + action: 'destroy_info', + project_id: 'testproject') } end end diff --git a/spec/services/add_attachment_service_spec.rb b/spec/services/add_attachment_service_spec.rb new file mode 100644 index 00000000000..97bc8ffaec2 --- /dev/null +++ b/spec/services/add_attachment_service_spec.rb @@ -0,0 +1,87 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. + +require 'spec_helper' + +describe AddAttachmentService do + let(:user) { FactoryGirl.create(:user) } + let(:work_package) { FactoryGirl.build(:work_package) } + let(:container) { work_package } + let(:description) { 'a fancy description' } + + subject { described_class.new(work_package, author: user) } + + describe '#add_attachment' do + def call_tested_method + subject.add_attachment uploaded_file: FileHelpers.mock_uploaded_file(name: 'foobar.txt'), + description: description + end + + context 'happy path' do + before do + call_tested_method + end + + it 'should save the attachment' do + attachment = Attachment.first + expect(attachment.filename).to eq 'foobar.txt' + expect(attachment.description).to eq description + end + + it 'should add the attachment to the WP' do + work_package.reload + expect(work_package.attachments).to include Attachment.first + end + + it 'should add a journal entry on the WP' do + expect(work_package.journals.count).to eq 2 # 1 for WP creation + 1 for the attachment + end + end + + context "can't save work package" do + before do + allow(work_package).to receive(:save!) + .and_raise(ActiveRecord::RecordInvalid.new(work_package)) + end + + it 'should raise the exception' do + expect { call_tested_method }.to raise_error ActiveRecord::RecordInvalid + end + + it 'should not save the attachment' do + begin + call_tested_method + rescue ActiveRecord::RecordInvalid + # we expect that to happen + end + + expect(Attachment.count).to eq 0 + end + end + end +end diff --git a/spec/app/services/create_work_package_service_spec.rb b/spec/services/create_work_package_service_spec.rb similarity index 100% rename from spec/app/services/create_work_package_service_spec.rb rename to spec/services/create_work_package_service_spec.rb diff --git a/spec/services/move_work_package_service_spec.rb b/spec/services/move_work_package_service_spec.rb new file mode 100644 index 00000000000..09493702fb1 --- /dev/null +++ b/spec/services/move_work_package_service_spec.rb @@ -0,0 +1,493 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe MoveWorkPackageService, type: :model do + let(:user) { FactoryGirl.create(:user) } + let(:type) { FactoryGirl.create(:type_standard) } + let(:project) { FactoryGirl.create(:project, types: [type]) } + let(:work_package) { + FactoryGirl.create(:work_package, + project: project, + type: type) + } + let(:instance) { MoveWorkPackageService.new(work_package, user) } + + before do + allow(User).to receive(:current).and_return(user) + end + + def mock_allowed_to_move_to_project(project, is_allowed = true) + allowed_scope = double('allowed_scope', :'exists?' => is_allowed) + + allow(WorkPackage) + .to receive(:allowed_target_projects_on_move) + .with(user) + .and_return(allowed_scope) + + allow(allowed_scope) + .to receive(:where) + .with(id: project.id) + .and_return(allowed_scope) + end + + describe '#call' do + context 'when moving' do + let(:target_project) { FactoryGirl.create(:project) } + + before do + work_package + + mock_allowed_to_move_to_project(target_project, true) + end + + shared_examples_for 'moved work package' do + subject { work_package.project } + + it { is_expected.to eq(target_project) } + end + + context 'the project the work package is moved to' do + it_behaves_like 'moved work package' do + before do + instance.call(target_project) + end + end + + it 'will not move if the user does not have the permission' do + mock_allowed_to_move_to_project(target_project, false) + + instance.call(target_project) + + expect(work_package.project).to eql(project) + end + end + + describe '#time_entries' do + let(:time_entry_1) { + FactoryGirl.create(:time_entry, + project: project, + work_package: work_package) + } + let(:time_entry_2) { + FactoryGirl.create(:time_entry, + project: project, + work_package: work_package) + } + + before do + time_entry_1 + time_entry_2 + + work_package.reload + instance.call(target_project) + + time_entry_1.reload + time_entry_2.reload + end + + context 'time entry 1' do + subject { work_package.time_entries } + + it { is_expected.to include(time_entry_1) } + end + + context 'time entry 2' do + subject { work_package.time_entries } + + it { is_expected.to include(time_entry_2) } + end + + it_behaves_like 'moved work package' + end + + describe '#category' do + let(:category) { + FactoryGirl.create(:category, + project: project) + } + + before do + work_package.category = category + work_package.save! + + work_package.reload + end + + context 'with same category' do + let(:target_category) { + FactoryGirl.create(:category, + name: category.name, + project: target_project) + } + + before do + target_category + + instance.call(target_project) + end + + describe 'category moved' do + subject { work_package.category_id } + + it { is_expected.to eq(target_category.id) } + end + + it_behaves_like 'moved work package' + end + + context 'w/o target category' do + before do + instance.call(target_project) + end + + describe 'category discarded' do + subject { work_package.category_id } + + it { is_expected.to be_nil } + end + + it_behaves_like 'moved work package' + end + end + + describe '#version' do + let(:sharing) { 'none' } + let(:version) { + FactoryGirl.create(:version, + status: 'open', + project: project, + sharing: sharing) + } + let(:work_package) { + FactoryGirl.create(:work_package, + fixed_version: version, + project: project) + } + + before do + instance.call(target_project) + end + + it_behaves_like 'moved work package' + + context 'unshared version' do + subject { work_package.fixed_version } + + it { is_expected.to be_nil } + end + + context 'system wide shared version' do + let(:sharing) { 'system' } + + subject { work_package.fixed_version } + + it { is_expected.to eq(version) } + end + + context 'move work package in project hierarchy' do + let(:target_project) { + FactoryGirl.create(:project, + parent: project) + } + + context 'unshared version' do + subject { work_package.fixed_version } + + it { is_expected.to be_nil } + end + + context 'shared version' do + let(:sharing) { 'tree' } + + subject { work_package.fixed_version } + + it { is_expected.to eq(version) } + end + end + end + + describe '#type' do + let(:target_type) { FactoryGirl.create(:type) } + let(:target_project) { + FactoryGirl.create(:project, + types: [target_type]) + } + + it 'is false if the current type is not defined for the new project' do + expect(instance.call(target_project)).to be_falsey + end + end + end + + describe 'when copying' do + let(:custom_field) { FactoryGirl.create(:work_package_custom_field) } + let(:source_type) { + FactoryGirl.create(:type, + custom_fields: [custom_field]) + } + let(:source_project) { + FactoryGirl.create(:project, + types: [source_type]) + } + let(:work_package) { + FactoryGirl.create(:work_package, + project: source_project, + type: source_type, + author: user) + } + let(:custom_value) { + FactoryGirl.create(:work_package_custom_value, + custom_field: custom_field, + customized: work_package, + value: false) + } + + shared_examples_for 'copied work package' do + subject { copy.id } + + it { is_expected.not_to eq(work_package.id) } + end + + describe 'to the same project' do + let(:copy) { + mock_allowed_to_move_to_project(source_project) + instance.call(source_project, nil, copy: true) + } + + it_behaves_like 'copied work package' + + context 'project' do + subject { copy.project } + + it { is_expected.to eq(source_project) } + end + end + + describe 'to a different project' do + let(:target_type) { FactoryGirl.create(:type) } + let(:target_project) { + FactoryGirl.create(:project, + types: [target_type]) + } + let(:copy) do + mock_allowed_to_move_to_project(target_project) + instance.call(target_project, target_type, copy: true) + end + + it_behaves_like 'copied work package' + + context 'project' do + subject { copy.project_id } + + it { is_expected.to eq(target_project.id) } + end + + context 'type' do + subject { copy.type_id } + + it { is_expected.to eq(target_type.id) } + end + + context 'custom_fields' do + before do + custom_value + end + + subject { copy.custom_value_for(custom_field.id) } + + it { is_expected.to be_nil } + end + + describe '#attributes' do + let(:copy) { + mock_allowed_to_move_to_project(target_project) + instance.call(target_project, + target_type, + copy: true, + attributes: attributes) + } + + context 'assigned_to' do + let(:target_user) { FactoryGirl.create(:user) } + let(:target_project_member) { + FactoryGirl.create(:member, + project: target_project, + principal: target_user, + roles: [FactoryGirl.create(:role)]) + } + let(:attributes) { { assigned_to_id: target_user.id } } + + before do + target_project_member + end + + it_behaves_like 'copied work package' + + subject { copy.assigned_to_id } + + it { is_expected.to eq(target_user.id) } + end + + context 'status' do + let(:target_status) { FactoryGirl.create(:status) } + let(:attributes) { { status_id: target_status.id } } + + it_behaves_like 'copied work package' + + subject { copy.status_id } + + it { is_expected.to eq(target_status.id) } + end + + context 'date' do + let(:target_date) { Date.today + 14 } + + context 'start' do + let(:attributes) { { start_date: target_date } } + + it_behaves_like 'copied work package' + + subject { copy.start_date } + + it { is_expected.to eq(target_date) } + end + + context 'end' do + let(:attributes) { { due_date: target_date } } + + it_behaves_like 'copied work package' + + subject { copy.due_date } + + it { is_expected.to eq(target_date) } + end + end + end + + describe 'private project' do + let(:role) { + FactoryGirl.create(:role, + permissions: [:view_work_packages]) + } + let(:target_project) { + FactoryGirl.create(:project, + is_public: false, + types: [target_type]) + } + let(:source_project_member) { + FactoryGirl.create(:member, + project: source_project, + principal: user, + roles: [role]) + } + + before do + source_project_member + allow(User).to receive(:current).and_return user + end + + it_behaves_like 'copied work package' + + context 'pre-condition' do + subject { work_package.recipients } + + it { is_expected.to include(work_package.author) } + end + + subject { copy.recipients } + + it { is_expected.not_to include(copy.author) } + end + + describe 'with children' do + let(:target_project) { FactoryGirl.create(:project, types: [source_type]) } + let(:instance) { MoveWorkPackageService.new(child, user) } + let(:copy) do + mock_allowed_to_move_to_project(target_project) + + child.reload + + instance.call(target_project) + end + let!(:child) { + FactoryGirl.create(:work_package, parent: work_package, project: source_project) + } + let!(:grandchild) { + FactoryGirl.create(:work_package, parent: child, project: source_project) + } + + context 'cross project relations deactivated' do + before do + allow(Setting).to receive(:cross_project_work_package_relations?).and_return(false) + end + + it do + expect(copy).to be_falsy + end + + it do + expect(child.reload.project).to eql(source_project) + end + + describe 'grandchild' do + before do + copy + end + + it { expect(grandchild.reload.project).to eql(source_project) } + end + end + + context 'cross project relations activated' do + before do + allow(Setting).to receive(:cross_project_work_package_relations?).and_return(true) + end + + it do + expect(copy).to be_truthy + end + + it do + expect(copy.project).to eql(target_project) + end + + describe 'grandchild' do + before do + copy + end + + it { expect(grandchild.reload.project).to eql(target_project) } + end + end + end + end + end + end +end diff --git a/spec/support/identical_ext.rb b/spec/support/identical_ext.rb index f59c24a3602..cf2ffb402c6 100644 --- a/spec/support/identical_ext.rb +++ b/spec/support/identical_ext.rb @@ -34,9 +34,9 @@ Journal.class_eval do recreated = o.attributes original.except!('created_at') - changed_data.except!('created_on') + details.except!('created_on') recreated.except!('created_at') - o.changed_data.except!('created_on') + o.details.except!('created_on') original.identical?(recreated) end diff --git a/spec/support/repository_helpers.rb b/spec/support/repository_helpers.rb new file mode 100644 index 00000000000..ab1d4bf01da --- /dev/null +++ b/spec/support/repository_helpers.rb @@ -0,0 +1,85 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +## +# Create a temporary +vendor+ repository from the stored fixture. +# Automatically extracts and destroys said repository, +# however does not provide single example isolation +# due to performance. +# As we do not write to the repository, we don't need this kind +# of isolation. +def with_filesystem_repository(vendor, command = nil, &block) + repo_dir = File.join(Rails.root, 'tmp', 'test', "#{vendor}_repository") + fixture = File.join(Rails.root, "spec/fixtures/repositories/#{vendor}_repository.tar.gz") + + before(:all) do + ['tar', command].compact.each do |cmd| + begin + # Avoid `which`, as it's not POSIX + Open3.capture2e(cmd, '--version') + rescue Errno::ENOENT + skip "#{cmd} was not found in PATH. Skipping local repository specs" + end + end + + # Create repository + FileUtils.mkdir_p repo_dir + system "tar -xzf #{fixture} -C #{repo_dir}" + end + + after(:all) do + FileUtils.remove_dir repo_dir + end + + block.call(repo_dir) +end + +def with_subversion_repository(&block) + with_filesystem_repository('subversion', 'svn', &block) +end + +def with_git_repository(&block) + with_filesystem_repository('git', 'git', &block) +end + +## +# Many specs required any repository to be available, +# often Filesystem adapter was used, even though +# no actual filesystem access occured. +# Instead, we wrap these repository specs in a virtual +# subversion repository which does not exist on disk. +def with_virtual_subversion_repository(&block) + let(:repository) { FactoryGirl.create(:repository_subversion) } + + before do + allow(Setting).to receive(:enabled_scm).and_return(['Subversion']) + end + + block.call +end diff --git a/spec/support/scm/countable_repository.rb b/spec/support/scm/countable_repository.rb new file mode 100644 index 00000000000..745b69ee684 --- /dev/null +++ b/spec/support/scm/countable_repository.rb @@ -0,0 +1,59 @@ +shared_examples_for 'is a countable repository' do + let(:job) { ::Scm::StorageUpdaterJob.new repository } + before do + allow(::Scm::StorageUpdaterJob).to receive(:new).and_return(job) + allow(job).to receive(:repository).and_return(repository) + end + it 'is countable' do + expect(repository.scm).to be_storage_available + end + + context 'with patched counter' do + let(:count) { 1234 } + + before do + allow(repository.scm).to receive(:count_repository!).and_return(count) + end + + it 'has has not been counted initially' do + expect(repository.required_storage_bytes).to be == 0 + expect(repository.storage_updated_at).to be_nil + end + + it 'counts the repository storage automatically' do + expect(repository.required_storage_bytes).to be == 0 + expect(repository.required_disk_storage).to be == count + expect(repository.storage_updated_at).to be >= 1.minute.ago + end + + context 'when latest count is outdated' do + before do + allow(repository).to receive(:storage_updated_at).and_return(24.hours.ago) + end + + it 'sucessfuly updates the count to what the adapter returns' do + expect(repository.required_storage_bytes).to be == 0 + expect(repository.required_disk_storage).to be == count + end + end + end + + context 'with real counter' do + it 'counts the repository storage automatically' do + expect(repository.required_storage_bytes).to be == 0 + expect(repository.required_disk_storage).to be > 1.kilobyte + expect(repository.storage_updated_at).to be >= 1.minute.ago + end + end +end + +shared_examples_for 'is not a countable repository' do + it 'is not countable' do + expect(repository.scm).not_to be_storage_available + end + + it 'does not return or update the count' do + expect(::Scm::StorageUpdaterJob).not_to receive(:new) + expect(repository.required_disk_storage).to be_nil + end +end diff --git a/spec/support/shared/acts_as_watchable.rb b/spec/support/shared/acts_as_watchable.rb index d3338b7a9fc..ddfae18b09e 100644 --- a/spec/support/shared/acts_as_watchable.rb +++ b/spec/support/shared/acts_as_watchable.rb @@ -133,7 +133,7 @@ MESSAGE subject { model_instance.watcher_recipients } - it { is_expected.to match_array([watching_user.mail]) } + it { is_expected.to match_array([watching_user]) } context 'when the permission to watch has been removed' do before do diff --git a/app/models/notifier.rb b/spec/support/tempdir.rb similarity index 89% rename from app/models/notifier.rb rename to spec/support/tempdir.rb index d1611b906a6..90a63da3ed0 100644 --- a/app/models/notifier.rb +++ b/spec/support/tempdir.rb @@ -26,13 +26,12 @@ # # See doc/COPYRIGHT.rdoc for more details. #++ - -module Notifier - def self.notify?(event) - notified_events.include?(event.to_s) - end - - def self.notified_events - Setting.notified_events.to_a +shared_context 'with tmpdir' do + around do |example| + Dir.mktmpdir do |dir| + @tmpdir = dir + example.run + end end + attr_reader :tmpdir end diff --git a/spec/workers/mail_notification_jobs/deliver_work_package_notification_job_spec.rb b/spec/workers/mail_notification_jobs/deliver_work_package_notification_job_spec.rb new file mode 100644 index 00000000000..dd8ba58c742 --- /dev/null +++ b/spec/workers/mail_notification_jobs/deliver_work_package_notification_job_spec.rb @@ -0,0 +1,164 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe DeliverWorkPackageNotificationJob, type: :model do + let(:project) { FactoryGirl.create(:project) } + let(:role) { FactoryGirl.create(:role, permissions: [:view_work_packages]) } + let(:recipient) { + FactoryGirl.create(:user, member_in_project: project, member_through_role: role) + } + let(:author) { FactoryGirl.create(:user) } + let(:work_package) { + FactoryGirl.create(:work_package, + project: project, + author: author, + assigned_to: recipient) + } + let(:journal) { work_package.journals.first } + subject { described_class.new(journal.id, author.id) } + + before do + # make sure no actual calls make it into the UserMailer + allow(UserMailer).to receive(:work_package_added).and_return(double('mail', deliver: nil)) + allow(UserMailer).to receive(:work_package_updated).and_return(double('mail', deliver: nil)) + end + + it 'sends a mail' do + expect(UserMailer).to receive(:work_package_added).with( + recipient, + an_instance_of(Journal::AggregatedJournal), + author) + subject.perform + end + + context 'non-existant journal' do + before do + journal.destroy + end + + it 'sends no mail' do + expect(UserMailer).not_to receive(:work_package_added) + subject.perform + end + end + + context 'non-existant author' do + before do + author.destroy + end + + it 'sends a mail' do + expect(UserMailer).to receive(:work_package_added) + subject.perform + end + + it 'uses the deleted user as author' do + expect(UserMailer).to receive(:work_package_added) + .with(anything, anything, DeletedUser.first) + + subject.perform + end + end + + context 'outdated journal' do + before do + # make sure there is a later journal, that supersedes the original one + work_package.subject = 'changed subject' + work_package.save! + end + + it 'raises an error' do + expect { subject.perform }.to raise_error('aggregated journal got outdated') + end + end + + context 'update journal' do + let(:journal) { work_package.journals.last } + + before do + work_package.add_journal(FactoryGirl.create(:user), 'a comment') + work_package.save! + end + + it 'sends an update mail' do + expect(UserMailer).to receive(:work_package_updated) + subject.perform + end + + it 'sends a mail for the aggregated journal' do + expected = Journal::AggregatedJournal.aggregated_journals(journable: work_package).last + expect(UserMailer).to receive(:work_package_updated) do |_recipient, journal, _author| + expect(journal.id).to eq expected.id + expect(journal.notes_id).to eq expected.notes_id + + double('mail', deliver: nil) + end + subject.perform + end + end + + describe 'impersonation' do + describe 'the recipient should become the current user during mail creation' do + before do + expect(UserMailer).to receive(:work_package_added) do + expect(User.current).to eql(recipient) + double('mail', deliver: nil) + end + end + + it { subject.perform } + end + + context 'for a known current user' do + let(:current_user) { FactoryGirl.create(:user) } + + it 'resets to the previous current user after running' do + User.current = current_user + subject.perform + expect(User.current).to eql(current_user) + end + end + end + + describe 'exceptions' do + describe 'exceptions should be raised' do + before do + mail = double('mail') + allow(mail).to receive(:deliver).and_raise(SocketError) + expect(UserMailer).to receive(:work_package_added).and_return(mail) + end + + it 'raises the error' do + expect { subject.perform }.to raise_error(SocketError) + end + end + end +end diff --git a/spec/workers/mail_notification_jobs/deliver_work_package_updated_spec.rb b/spec/workers/mail_notification_jobs/deliver_work_package_updated_spec.rb deleted file mode 100644 index bd9e0d162c9..00000000000 --- a/spec/workers/mail_notification_jobs/deliver_work_package_updated_spec.rb +++ /dev/null @@ -1,66 +0,0 @@ -#-- encoding: UTF-8 -#-- copyright -# OpenProject is a project management system. -# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See doc/COPYRIGHT.rdoc for more details. -#++ - -require 'spec_helper' -require 'workers/mail_notification_jobs/shared_examples' - -describe DeliverWorkPackageUpdatedJob, type: :model do - let(:work_package) do - FactoryGirl.create(:work_package).tap do |wp| - wp.subject = mail_subject - wp.save # create journal - end - end - - let(:current_user) { FactoryGirl.create :user } - let(:journal) { work_package.journals.last } - let(:job) { DeliverWorkPackageUpdatedJob.new user.id, journal.id, current_user.id } - - it_behaves_like 'a mail notification job' do - context 'with journal not found' do - let(:mail_subject) { 'no journal found! :/' } - - before do - journal.destroy - end - - it_behaves_like 'job cannot find record' - end - - context 'with current user not found' do - let(:mail_subject) { 'current user not found! :x' } - - before do - current_user.destroy - end - - it_behaves_like 'job cannot find record' - end - end -end diff --git a/spec/workers/mail_notification_jobs/enqueue_work_package_notification_job_spec.rb b/spec/workers/mail_notification_jobs/enqueue_work_package_notification_job_spec.rb new file mode 100644 index 00000000000..d8195c9b1dd --- /dev/null +++ b/spec/workers/mail_notification_jobs/enqueue_work_package_notification_job_spec.rb @@ -0,0 +1,216 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe EnqueueWorkPackageNotificationJob, type: :model do + let(:project) { FactoryGirl.create(:project) } + let(:role) { FactoryGirl.create(:role, permissions: [:view_work_packages]) } + let(:recipient) { + FactoryGirl.create(:user, member_in_project: project, member_through_role: role) + } + let(:author) { FactoryGirl.create(:user) } + let(:work_package) { + FactoryGirl.create(:work_package, + project: project, + author: author, + assigned_to: recipient) + } + let(:journal) { work_package.journals.first } + subject { described_class.new(journal.id, author.id) } + + before do + # make sure no other calls are made due to WP creation/update + allow(OpenProject::Notifications).to receive(:send) # ... and do nothing + end + + it 'sends a mail' do + expect(Delayed::Job).to receive(:enqueue).with(an_instance_of DeliverWorkPackageNotificationJob) + subject.perform + end + + context 'non-existant journal' do + before do + journal.destroy + end + + it 'sends no mail' do + expect(Delayed::Job).not_to receive(:enqueue) + subject.perform + end + end + + context 'non-existant author' do + before do + author.destroy + end + + it 'sends a mail' do + expect(Delayed::Job).to receive(:enqueue) + .with(an_instance_of DeliverWorkPackageNotificationJob) + subject.perform + end + end + + context 'outdated journal' do + before do + # make sure there is a later journal, that supersedes the original one + work_package.subject = 'changed subject' + work_package.save! + end + + it 'does not send any mails' do + expect(Delayed::Job).not_to receive(:enqueue) + subject.perform + end + end + + describe 'mail suppressing aggregation' do + # business logic of whether to send or not to send a mail is mainly driven by the presence + # of an aggregated journal. However, there is an edge case that could lead to a notification + # getting lost. Sadly this is very implementation specific, so I'll describe it: + # Journal 1: comment + # Journal 2: change (this can also be multiple journals) + # Journal 3: comment + # + # The Job for the first journal will not send any mail, because Journal 2 supersedes it. + # However, after adding Journal 3, the aggregation will look like (1), (2, 3). Therefore the + # job for Journal 2 will not send a notification. Finally the job for Journal 3 will send a + # notification, but only containing the changes of 2 and 3. The comment of journal 1 is lost. + # Therefore two things have to happen: + # - someone needs to send notifications for the hidden journal + # (done by JournalNotificationMailer) + # - in case a journal is hidden, its Job is not allowed to enqueue a mail for it + # (because someone else will do it on behalf) + # This is important since late exec of a Job might cause it to _not_ skip notifications + + before do + change = { subject: 'new subject' } + note = { notes: 'a comment' } + + expect(work_package.update_by!(author, note)).to be_truthy + work_package.reload + expect(work_package.update_by!(author, change)).to be_truthy + work_package.reload + expect(work_package.update_by!(author, note)).to be_truthy + end + + let(:timeout) { Setting.journal_aggregation_time_minutes.to_i.minutes } + let(:journal_1) { work_package.journals[1] } + let(:journal_2) { work_package.journals[2] } + let(:journal_3) { work_package.journals[3] } + + context 'all changes happen within the timeout of journal 1' do + # The job for 1 will know, that Journal 3 took its addition. + # The job for 2 will know, that it has become part of Journal 3. + # -> no special behaviour required + + it 'Job 1 sends one mail for journal 1' do + expect(Delayed::Job).to receive(:enqueue) + .with(an_instance_of DeliverWorkPackageNotificationJob) + .once + described_class.new(journal_1.id, author.id).perform + end + + it 'Job 2 sends no mails' do + expect(Delayed::Job).not_to receive(:enqueue) + described_class.new(journal_2.id, author.id).perform + end + + it 'Job 3 sends one mail for journal (2,3)' do + expect(Delayed::Job).to receive(:enqueue) + .with(an_instance_of DeliverWorkPackageNotificationJob) + .once + described_class.new(journal_3.id, author.id).perform + end + end + + context 'journal 3 created after timeout of 1, but inside of timeout for 2' do + # Job 1 will not send a mail because it does not know about journal 3 + # (thinking 2 will take its mail) + # The mail of Job 1 is taken over by the JournalNotificationMailer for Journal 3 + # Even if Job 1 knew of journal 3 (due to late execution), it was not allowed to send a mail + # (that would cause a duplicate mail delivery) + + before do + journal_2.created_at = journal_1.created_at + (timeout / 2) + journal_3.created_at = journal_1.created_at + timeout + 5.seconds + journal_2.save! + journal_3.save! + end + + it 'Job 1 sends no mails' do + expect(Delayed::Job).not_to receive(:enqueue) + described_class.new(journal_1.id, author.id).perform + end + + it 'Job 2 sends no mails' do + expect(Delayed::Job).not_to receive(:enqueue) + described_class.new(journal_2.id, author.id).perform + end + + it 'Job 3 sends one mail for (2,3)' do + expect(Delayed::Job).to receive(:enqueue) + .with(an_instance_of DeliverWorkPackageNotificationJob) + .once + described_class.new(journal_3.id, author.id).perform + end + end + + context 'journal 3 created after timeout of 1 and 2' do + # This is a normal case again, ensuring nobody takes responsiblity when not neccessary. + + before do + journal_2.created_at = journal_1.created_at + (timeout / 2) + journal_3.created_at = journal_2.created_at + timeout + 5.seconds + journal_2.save! + journal_3.save! + end + + it 'Job 1 sends no mails' do + expect(Delayed::Job).not_to receive(:enqueue) + described_class.new(journal_1.id, author.id).perform + end + + it 'Job 2 sends one mail for journal (1, 2)' do + expect(Delayed::Job).to receive(:enqueue) + .with(an_instance_of DeliverWorkPackageNotificationJob) + .once + described_class.new(journal_2.id, author.id).perform + end + + it 'Job 3 sends one mail for journal 3' do + expect(Delayed::Job).to receive(:enqueue) + .with(an_instance_of DeliverWorkPackageNotificationJob) + .once + described_class.new(journal_3.id, author.id).perform + end + end + end +end diff --git a/spec/workers/mail_notification_jobs/mail_notification_job_spec.rb b/spec/workers/mail_notification_jobs/mail_notification_job_spec.rb deleted file mode 100644 index 89bf602ec2f..00000000000 --- a/spec/workers/mail_notification_jobs/mail_notification_job_spec.rb +++ /dev/null @@ -1,70 +0,0 @@ -#-- encoding: UTF-8 -#-- copyright -# OpenProject is a project management system. -# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See doc/COPYRIGHT.rdoc for more details. -#++ - -require 'spec_helper' - -describe MailNotificationJob, type: :model do - class StubNoticationJob < MailNotificationJob - def initialize(recipient_id, author_id, mail_callback) - super(recipient_id, author_id) - @mail_callback = mail_callback - end - - def notification_mail - @mail_callback.call - end - end - - let(:recipient) { FactoryGirl.create(:user) } - let(:author) { FactoryGirl.create(:user) } - let(:mail) { double('a mail', deliver: nil) } - let(:mail_callback) { -> { mail } } - subject { StubNoticationJob.new(recipient.id, author.id, mail_callback) } - - describe 'the recipient should become the current user during mail creation' do - let(:mail_callback) { - -> { - expect(User.current).to eql(recipient) - mail - } - } - - it { subject.perform } - end - - context 'for a known current user' do - let(:current_user) { FactoryGirl.create(:user) } - - it 'resets to the previous current user after running' do - User.current = current_user - subject.perform - expect(User.current).to eql(current_user) - end - end -end diff --git a/spec/workers/mail_notification_jobs/shared_examples.rb b/spec/workers/mail_notification_jobs/shared_examples.rb deleted file mode 100644 index 519667f6b7a..00000000000 --- a/spec/workers/mail_notification_jobs/shared_examples.rb +++ /dev/null @@ -1,100 +0,0 @@ -#-- encoding: UTF-8 -#-- copyright -# OpenProject is a project management system. -# Copyright (C) 2012-2015 the OpenProject Foundation (OPF) -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See doc/COPYRIGHT.rdoc for more details. -#++ - -require 'spec_helper' - -## -# Shared example expecting #mail_subject and #job to be defined where -# the former is looked for in the sent email's subject and the latter is -# the delayed job to be performed. -# Tests that mail notifications are sent in successful cases, -# that none are sent but the job finishes nontheless in cases were necessary -# records are missing (e.g. the user to be notified), and that jobs with any other -# sort of error fail as expected in order to be rescheduled. -shared_examples 'a mail notification job' do - let!(:user) { FactoryGirl.create :user } # user to be notified - - context 'with all records found' do - let(:mail_subject) { 'all records found!' } - - before do - job.perform - end - - it 'sends an email' do - mail = ActionMailer::Base.deliveries.detect { |m| m.subject.include? mail_subject } - - expect(mail).to be_present - end - end - - shared_examples 'job cannot find record' do - it 'does not send an email' do - job.perform - mail = ActionMailer::Base.deliveries.detect { |m| m.subject.include? mail_subject } - - expect(mail).not_to be_present - end - - it 'does not raise an error but fails silently' do - expect { job.perform }.not_to raise_error - end - - context 'raising exceptions' do - before { MailNotificationJob.raise_exceptions = true } - after { MailNotificationJob.raise_exceptions = false } - - it 'raises an error' do - expect { job.perform }.to raise_error(ActiveRecord::RecordNotFound) - end - end - end - - context 'with user not found' do - let(:mail_subject) { 'no user found :(' } - - before do - user.destroy - end - - it_behaves_like 'job cannot find record' - end - - context 'with unexpected error' do - let(:mail_subject) { "don't care" } - - before do - expect_any_instance_of(Mail::Message).to receive(:deliver).and_raise(SocketError) - end - - it 'raises said error' do - expect { job.perform }.to raise_error(SocketError) - end - end -end