Merge branch 'dev' into feature/32881-children-duration-bar-on-gantt-charts

pull/8569/head
Aleix Suau 4 years ago
commit eb35c959df
  1. 59
      .travis.yml
  2. 1
      Gemfile
  3. 7
      Gemfile.lock
  4. 28
      app/models/attachment.rb
  5. 8
      app/models/queries/work_packages/filter/estimated_hours_filter.rb
  6. 2
      app/models/version.rb
  7. 52
      app/uploaders/direct_fog_uploader.rb
  8. 5
      app/workers/attachments/cleanup_uncontainered_job.rb
  9. 53
      app/workers/attachments/finish_direct_upload_job.rb
  10. 2
      config/initializers/carrierwave.rb
  11. 4
      config/initializers/secure_headers.rb
  12. 59
      config/locales/crowdin/ar.yml
  13. 3
      config/locales/crowdin/bg.yml
  14. 3
      config/locales/crowdin/ca.yml
  15. 3
      config/locales/crowdin/cs.yml
  16. 3
      config/locales/crowdin/da.yml
  17. 5
      config/locales/crowdin/de.yml
  18. 3
      config/locales/crowdin/el.yml
  19. 59
      config/locales/crowdin/es.yml
  20. 3
      config/locales/crowdin/fi.yml
  21. 3
      config/locales/crowdin/fil.yml
  22. 3
      config/locales/crowdin/fr.yml
  23. 3
      config/locales/crowdin/hr.yml
  24. 3
      config/locales/crowdin/hu.yml
  25. 3
      config/locales/crowdin/id.yml
  26. 3
      config/locales/crowdin/it.yml
  27. 3
      config/locales/crowdin/ja.yml
  28. 16
      config/locales/crowdin/js-es.yml
  29. 3
      config/locales/crowdin/ko.yml
  30. 3
      config/locales/crowdin/lt.yml
  31. 3
      config/locales/crowdin/nl.yml
  32. 3
      config/locales/crowdin/no.yml
  33. 3
      config/locales/crowdin/pl.yml
  34. 3
      config/locales/crowdin/pt.yml
  35. 3
      config/locales/crowdin/ro.yml
  36. 3
      config/locales/crowdin/ru.yml
  37. 3
      config/locales/crowdin/sk.yml
  38. 3
      config/locales/crowdin/sl.yml
  39. 3
      config/locales/crowdin/sv.yml
  40. 3
      config/locales/crowdin/tr.yml
  41. 3
      config/locales/crowdin/uk.yml
  42. 3
      config/locales/crowdin/vi.yml
  43. 3
      config/locales/crowdin/zh-CN.yml
  44. 3
      config/locales/crowdin/zh-TW.yml
  45. 1
      config/locales/js-en.yml
  46. 16
      docs/development/running-tests/README.md
  47. 16
      docs/installation-and-operations/configuration/README.md
  48. 2
      frontend/src/app/angular4-modules.ts
  49. 14
      frontend/src/app/components/api/api-v3/api-v3-filter-builder.ts
  50. 148
      frontend/src/app/components/api/op-file-upload/op-direct-file-upload.service.ts
  51. 2
      frontend/src/app/components/api/op-file-upload/op-file-upload.service.spec.ts
  52. 2
      frontend/src/app/components/wp-edit-form/work-package-filter-values.spec.ts
  53. 8
      frontend/src/app/helpers/rxjs/debounced-input-switchmap.ts
  54. 4
      frontend/src/app/modules/apiv3/endpoints/work_packages/work-package-cache.spec.ts
  55. 46
      frontend/src/app/modules/boards/board/add-list-modal/add-list-modal.component.ts
  56. 15
      frontend/src/app/modules/boards/board/add-list-modal/add-list-modal.html
  57. 9
      frontend/src/app/modules/boards/board/board-actions/assignee/assignee-action.service.ts
  58. 66
      frontend/src/app/modules/boards/board/board-actions/board-action.service.ts
  59. 62
      frontend/src/app/modules/boards/board/board-actions/cached-board-action.service.ts
  60. 10
      frontend/src/app/modules/boards/board/board-actions/status/status-action.service.ts
  61. 25
      frontend/src/app/modules/boards/board/board-actions/subproject/subproject-action.service.ts
  62. 62
      frontend/src/app/modules/boards/board/board-actions/subtasks/board-subtasks-action.service.ts
  63. 57
      frontend/src/app/modules/boards/board/board-actions/subtasks/subtasks-board-header.component.ts
  64. 14
      frontend/src/app/modules/boards/board/board-actions/subtasks/subtasks-board-header.html
  65. 7
      frontend/src/app/modules/boards/board/board-actions/subtasks/subtasks-board-header.sass
  66. 9
      frontend/src/app/modules/boards/board/board-actions/version/version-action.service.ts
  67. 2
      frontend/src/app/modules/boards/board/board-list/board-list.component.ts
  68. 2
      frontend/src/app/modules/boards/board/board-partitioned-page/board-list-container.component.ts
  69. 15
      frontend/src/app/modules/boards/boards-root/boards-root.component.ts
  70. 4
      frontend/src/app/modules/boards/openproject-boards.module.ts
  71. 1
      frontend/src/app/modules/common/autocomplete/create-autocompleter.component.html
  72. 8
      frontend/src/app/modules/common/config/configuration.service.ts
  73. 20
      frontend/src/app/modules/common/notifications/upload-progress.component.ts
  74. 29
      frontend/src/app/modules/hal/resources/mixins/attachable-mixin.ts
  75. 2
      frontend/src/app/modules/hal/resources/work-package-resource.spec.ts
  76. 12
      frontend/src/app/modules/hal/resources/work-package-resource.ts
  77. 6
      lib/api/errors/not_found.rb
  78. 9
      lib/api/v3/attachments/attachable_representer_mixin.rb
  79. 4
      lib/api/v3/attachments/attachment_metadata_representer.rb
  80. 158
      lib/api/v3/attachments/attachment_upload_representer.rb
  81. 30
      lib/api/v3/attachments/attachments_api.rb
  82. 60
      lib/api/v3/attachments/attachments_by_container_api.rb
  83. 4
      lib/api/v3/attachments/attachments_by_post_api.rb
  84. 4
      lib/api/v3/attachments/attachments_by_wiki_page_api.rb
  85. 4
      lib/api/v3/attachments/attachments_by_work_package_api.rb
  86. 9
      lib/api/v3/configuration/configuration_representer.rb
  87. 11
      lib/api/v3/utilities/path_helper.rb
  88. 4
      lib/open_project/configuration.rb
  89. 28
      lib/open_project/configuration/helpers.rb
  90. 37
      lib/tasks/parallel_testing.rake
  91. 4
      modules/avatars/config/locales/crowdin/js-ar.yml
  92. 3
      modules/backlogs/app/models/sprint.rb
  93. 8
      modules/backlogs/config/locales/crowdin/ar.yml
  94. 8
      modules/bim/app/contracts/bim/ifc_models/base_contract.rb
  95. 87
      modules/bim/app/controllers/bim/ifc_models/ifc_models_controller.rb
  96. 12
      modules/bim/app/services/bim/ifc_models/set_attributes_service.rb
  97. 50
      modules/bim/app/views/bim/ifc_models/ifc_models/_form.html.erb
  98. 80
      modules/bim/config/locales/crowdin/ar.yml
  99. 2
      modules/bim/config/locales/crowdin/es.yml
  100. 6
      modules/bim/config/locales/crowdin/js-es.yml
  101. Some files were not shown because too many files have changed in this diff Show More

@ -90,68 +90,67 @@ jobs:
- bash script/ci/runner.sh npm - bash script/ci/runner.sh npm
- stage: test - stage: test
name: 'spec_legacy (1/1)' name: 'legacy specs + cukes (1/1)'
script: script:
- bash script/ci/setup.sh spec_legacy - bash script/ci/setup.sh spec_legacy
- bash script/ci/runner.sh spec_legacy 1 1 - bash script/ci/runner.sh spec_legacy 1 1
- bash script/ci/runner.sh plugins:cucumber 1 1
- stage: test - stage: test
name: 'units (1/4)' name: 'units (1/5)'
script: script:
- bash script/ci/setup.sh units - bash script/ci/setup.sh units
- bash script/ci/runner.sh units 4 1 - bash script/ci/runner.sh units 5 1
- stage: test - stage: test
name: 'units (2/4)' name: 'units (2/5)'
script: script:
- bash script/ci/setup.sh units - bash script/ci/setup.sh units
- bash script/ci/runner.sh units 4 2 - bash script/ci/runner.sh units 5 2
- stage: test - stage: test
name: 'units (3/4)' name: 'units (3/5)'
script: script:
- bash script/ci/setup.sh units - bash script/ci/setup.sh units
- bash script/ci/runner.sh units 4 3 - bash script/ci/runner.sh units 5 3
- stage: test - stage: test
name: 'units (4/4)' name: 'units (4/5)'
script: script:
- bash script/ci/setup.sh units - bash script/ci/setup.sh units
- bash script/ci/runner.sh units 4 4 - bash script/ci/runner.sh units 5 4
- stage: test - stage: test
name: 'features (1/4)' name: 'units (5/5)'
script: script:
- bash script/ci/setup.sh features - bash script/ci/setup.sh units
- bash script/ci/runner.sh features 4 1 - bash script/ci/runner.sh units 5 5
- stage: test - stage: test
name: 'features (2/4)' name: 'features (1/6)'
script: script:
- bash script/ci/setup.sh features - bash script/ci/setup.sh features
- bash script/ci/runner.sh features 4 2 - bash script/ci/runner.sh features 6 1
- stage: test - stage: test
name: 'features (3/4)' name: 'features (2/6)'
script: script:
- bash script/ci/setup.sh features - bash script/ci/setup.sh features
- bash script/ci/runner.sh features 4 3 - bash script/ci/runner.sh features 6 2
- stage: test - stage: test
name: 'features (4/4)' name: 'features (3/6)'
script: script:
- bash script/ci/setup.sh features - bash script/ci/setup.sh features
- bash script/ci/runner.sh features 4 4 - bash script/ci/runner.sh features 6 3
- stage: test - stage: test
name: 'plugins:units (1/1)' name: 'features (4/6)'
script: script:
- bash script/ci/setup.sh plugins:units - bash script/ci/setup.sh features
- bash script/ci/runner.sh plugins:units 1 1 - bash script/ci/runner.sh features 6 4
if: head_branch !~ /^core\//
- stage: test - stage: test
name: 'plugins:features (1/1)' name: 'features (5/6)'
script: script:
- bash script/ci/setup.sh plugins:features - bash script/ci/setup.sh features
- bash script/ci/runner.sh plugins:features 1 1 - bash script/ci/runner.sh features 6 5
if: head_branch !~ /^core\//
- stage: test - stage: test
name: 'plugins:cucumber (1/1)' name: 'features (6/6)'
script: script:
- bash script/ci/setup.sh plugins:cucumber - bash script/ci/setup.sh features
- bash script/ci/runner.sh plugins:cucumber 1 1 - bash script/ci/runner.sh features 6 6
if: head_branch !~ /^core\//
addons: addons:
chrome: stable chrome: stable

@ -169,6 +169,7 @@ gem 'puma', '~> 4.3.5' # used for development and optionally for production
gem 'nokogiri', '~> 1.10.8' gem 'nokogiri', '~> 1.10.8'
gem 'carrierwave', '~> 1.3.1' gem 'carrierwave', '~> 1.3.1'
gem 'carrierwave_direct', '~> 2.1.0'
gem 'fog-aws' gem 'fog-aws'
gem 'aws-sdk-core', '~> 3.91.0' gem 'aws-sdk-core', '~> 3.91.0'

@ -334,6 +334,9 @@ GEM
activemodel (>= 4.0.0) activemodel (>= 4.0.0)
activesupport (>= 4.0.0) activesupport (>= 4.0.0)
mime-types (>= 1.16) mime-types (>= 1.16)
carrierwave_direct (2.1.0)
carrierwave (>= 1.0.0)
fog-aws
cells (4.1.7) cells (4.1.7)
declarative-builder (< 0.2.0) declarative-builder (< 0.2.0)
declarative-option (< 0.2.0) declarative-option (< 0.2.0)
@ -567,7 +570,8 @@ GEM
multi_json (~> 1.0) multi_json (~> 1.0)
rspec (>= 2.0, < 4.0) rspec (>= 2.0, < 4.0)
kgio (2.11.3) kgio (2.11.3)
kramdown (2.1.0) kramdown (2.3.0)
rexml
kramdown-parser-gfm (1.1.0) kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0) kramdown (~> 2.0)
ladle (1.0.1) ladle (1.0.1)
@ -989,6 +993,7 @@ DEPENDENCIES
capybara (~> 3.32.0) capybara (~> 3.32.0)
capybara-screenshot (~> 1.0.17) capybara-screenshot (~> 1.0.17)
carrierwave (~> 1.3.1) carrierwave (~> 1.3.1)
carrierwave_direct (~> 2.1.0)
cells-erb (~> 0.1.0) cells-erb (~> 0.1.0)
cells-rails (~> 0.0.9) cells-rails (~> 0.0.9)
commonmarker (~> 0.21.0) commonmarker (~> 0.21.0)

@ -251,6 +251,34 @@ class Attachment < ApplicationRecord
end end
end end
def self.pending_direct_uploads
where(digest: "", downloads: -1)
end
def self.create_pending_direct_upload(file_name:, author:, container: nil, content_type: nil, file_size: 0)
a = create(
container: container,
author: author,
content_type: content_type.presence || "application/octet-stream",
filesize: file_size,
digest: "",
downloads: -1
)
# We need to do it like this because `file` is an uploader which expects a File (not a string)
# to upload usually. But in this case the data has already been uploaded and we just point to it.
a[:file] = file_name
a.save!
a.reload # necessary so that the fog file uploader path is correct
a
end
def pending_direct_upload?
digest == "" && downloads == -1
end
private private
def schedule_cleanup_uncontainered_job def schedule_cleanup_uncontainered_job

@ -32,4 +32,12 @@ class Queries::WorkPackages::Filter::EstimatedHoursFilter <
def type def type
:integer :integer
end end
def where
if operator == Queries::Operators::None.to_sym.to_s
super + " OR #{WorkPackage.table_name}.estimated_hours=0"
else
super
end
end
end end

@ -58,7 +58,7 @@ class Version < ApplicationRecord
scope :systemwide, -> { where(sharing: 'system') } scope :systemwide, -> { where(sharing: 'system') }
scope :order_by_name, -> { order(Arel.sql("LOWER(#{Version.table_name}.name)")) } scope :order_by_name, -> { order(Arel.sql("LOWER(#{Version.table_name}.name) ASC")) }
def self.with_status_open def self.with_status_open
where(status: 'open') where(status: 'open')

@ -0,0 +1,52 @@
require_relative 'fog_file_uploader'
class DirectFogUploader < FogFileUploader
include CarrierWaveDirect::Uploader
def self.for_attachment(attachment)
for_uploader attachment.file
end
def self.for_uploader(fog_file_uploader)
raise ArgumentError, "FogFileUploader expected" unless fog_file_uploader.is_a? FogFileUploader
uploader = self.new
uploader.instance_variable_set "@file", fog_file_uploader.file
uploader.instance_variable_set "@key", fog_file_uploader.path
uploader
end
##
# Generates the direct upload form for the given attachment.
#
# @param attachment [Attachment] The attachment for which a file is to be uploaded.
# @param success_action_redirect [String] URL to redirect to if successful (none by default, using status).
# @param success_action_status [String] The HTTP status to return on success (201 by default).
# @param max_file_size [Integer] The maximum file size to be allowed in bytes.
def self.direct_fog_hash(
attachment:,
success_action_redirect: nil,
success_action_status: "201",
max_file_size: Setting.attachment_max_size * 1024
)
uploader = for_attachment attachment
if success_action_redirect.present?
uploader.success_action_redirect = success_action_redirect
uploader.use_action_status = false
else
uploader.success_action_status = success_action_status
uploader.use_action_status = true
end
hash = uploader.direct_fog_hash(enforce_utf8: false, max_file_size: max_file_size)
if success_action_redirect.present?
hash.merge(success_action_redirect: success_action_redirect)
else
hash.merge(success_action_status: success_action_status)
end
end
end

@ -36,6 +36,11 @@ class Attachments::CleanupUncontaineredJob < ApplicationJob
.where(container: nil) .where(container: nil)
.where(too_old) .where(too_old)
.destroy_all .destroy_all
Attachment
.pending_direct_uploads
.where(too_old)
.destroy_all # prepared direct uploads that never finished
end end
private private

@ -0,0 +1,53 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
#
# 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
class Attachments::FinishDirectUploadJob < ApplicationJob
queue_with_priority :high
def perform(attachment_id)
attachment = Attachment.pending_direct_uploads.where(id: attachment_id).first
local_file = attachment && attachment.file.local_file
if local_file.nil?
return Rails.logger.error("File for attachment #{attachment_id} was not uploaded.")
end
begin
attachment.downloads = 0
attachment.set_file_size local_file unless attachment.filesize && attachment.filesize > 0
attachment.set_content_type local_file unless attachment.content_type.present?
attachment.set_digest local_file unless attachment.digest.present?
attachment.save! if attachment.changed?
ensure
File.unlink(local_file.path) if File.exist?(local_file.path)
end
end
end

@ -47,6 +47,8 @@ module CarrierWave
config.fog_credentials = { provider: provider }.merge(credentials) config.fog_credentials = { provider: provider }.merge(credentials)
config.fog_directory = directory config.fog_directory = directory
config.fog_public = public config.fog_public = public
config.use_action_status = true
end end
end end
end end

@ -21,7 +21,7 @@ SecureHeaders::Configuration.default do |config|
frame_src << OpenProject::Configuration[:security_badge_url] frame_src << OpenProject::Configuration[:security_badge_url]
# Default src # Default src
default_src = %w('self') default_src = %w('self') + [OpenProject::Configuration.remote_storage_host].compact
# Allow requests to CLI in dev mode # Allow requests to CLI in dev mode
connect_src = default_src connect_src = default_src
@ -56,7 +56,7 @@ SecureHeaders::Configuration.default do |config|
# Allow fonts from self, asset host, or DATA uri # Allow fonts from self, asset host, or DATA uri
font_src: assets_src + %w(data:), font_src: assets_src + %w(data:),
# Form targets can only be self # Form targets can only be self
form_action: %w('self'), form_action: default_src,
# Allow iframe from vimeo (welcome video) # Allow iframe from vimeo (welcome video)
frame_src: frame_src + %w('self'), frame_src: frame_src + %w('self'),
frame_ancestors: %w('self'), frame_ancestors: %w('self'),

@ -28,38 +28,38 @@ ar:
plugins: plugins:
no_results_title_text: لا يوجد حالياً أية إضافات متاحة. no_results_title_text: لا يوجد حالياً أية إضافات متاحة.
custom_styles: custom_styles:
color_theme: "Color theme" color_theme: "لون السمة"
color_theme_custom: "(Custom)" color_theme_custom: "(تخصيص)"
colors: colors:
alternative-color: "Alternative" alternative-color: "البديل"
content-link-color: "Link font" content-link-color: "خط الارتبط"
primary-color: "Primary" primary-color: "الأساسي"
primary-color-dark: "Primary (dark)" primary-color-dark: "الأساسي (داكن)"
header-bg-color: "Header background" header-bg-color: "خلفية الترويسة"
header-item-bg-hover-color: "Header background on hover" header-item-bg-hover-color: "خلفية الترويسة على الحافة"
header-item-font-color: "Header font" header-item-font-color: "خط الترويسة"
header-item-font-hover-color: "Header font on hover" header-item-font-hover-color: "خط الترويسة عند الحافة"
header-border-bottom-color: "Header border" header-border-bottom-color: "حدود الترويسة"
main-menu-bg-color: "Main menu background" main-menu-bg-color: "خلفية القائمة الرئيسية"
main-menu-bg-selected-background: "Main menu when selected" main-menu-bg-selected-background: "القائمة الرئيسية عند تحديد"
main-menu-bg-hover-background: "Main menu on hover" main-menu-bg-hover-background: "القائمة الرئيسية على الحافة"
main-menu-font-color: "Main menu font" main-menu-font-color: "خط القائمة الرئيسية"
main-menu-selected-font-color: "Main menu font when selected" main-menu-selected-font-color: "خط القائمة الرئيسية عند تحديد"
main-menu-hover-font-color: "Main menu font on hover" main-menu-hover-font-color: "خط القائمة الرئيسية عند الحوالة"
main-menu-border-color: "Main menu border" main-menu-border-color: "حدود القائمة الرئيسية"
custom_colors: "تخصيص الألوان" custom_colors: "تخصيص الألوان"
customize: "عدل مشروعك الخاص بالشعار الذي تريده.ملاحظه:هذا الشعار سوف يكون مرئي لجميع المستخدمين" customize: "عدل مشروعك الخاص بالشعار الذي تريده.ملاحظه:هذا الشعار سوف يكون مرئي لجميع المستخدمين"
enterprise_notice: "As a special 'Thank you!' for their financial contribution to develop OpenProject, this tiny feature is only available for Enterprise Edition support subscribers." enterprise_notice: "كخاصية \"شكرًا!\" على مساهمتهم المالية لتطوير OpenProject، هذه الميزة الصغيرة متاحة فقط للمشتركين في إصدار المؤسسة."
manage_colors: "Edit color select options" manage_colors: "تعديل خيارات تحديد اللون"
instructions: instructions:
alternative-color: "Strong accent color, typically used for the most important button on a screen." alternative-color: "لون اللكنة القوية، يستخدم عادة لأهم زر على الشاشة."
content-link-color: "Font color of most of the links." content-link-color: "لون الخط لمعظم الروابط."
primary-color: "Main color." primary-color: "اللون الرئيسي."
primary-color-dark: "Typically a darker version of the main color used for hover effects." primary-color-dark: "عادةً ما تكون نسخة داكنة من اللون الرئيسي المستخدم لتأثيرات الحرارة."
header-item-bg-hover-color: "Background color of clickable header items when hovered with the mouse." header-item-bg-hover-color: "لون الخلفية لعناصر الترويسة القابلة للنقر عند ربطها بالفأرة."
header-item-font-color: "Font color of clickable header items." header-item-font-color: "لون الخط لعناصر الترويسة النقر عليها."
header-item-font-hover-color: "Font color of clickable header items when hovered with the mouse." header-item-font-hover-color: "لون الخط لعناصر الترويسة القابلة للنقر عند ربطها بالفأرة."
header-border-bottom-color: "Thin line under the header. Leave this field empty if you don't want any line." header-border-bottom-color: "خط تحت الرأس. اترك هذا الحقل فارغاً إذا كنت لا تريد أي سطر."
main-menu-bg-color: "Left side menu's background color." main-menu-bg-color: "Left side menu's background color."
theme_warning: Changing the theme will overwrite you custom style. The design will then be lost. Are you sure you want to continue? theme_warning: Changing the theme will overwrite you custom style. The design will then be lost. Are you sure you want to continue?
enterprise: enterprise:
@ -756,6 +756,9 @@ ar:
date: "التاريخ" date: "التاريخ"
default_columns: "الأعمدة الافتراضية" default_columns: "الأعمدة الافتراضية"
description: "الوصف" description: "الوصف"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "عرض المبالغ" display_sums: "عرض المبالغ"
due_date: "Finish date" due_date: "Finish date"
estimated_hours: "الوقت المُقّدَّر" estimated_hours: "الوقت المُقّدَّر"

@ -740,6 +740,9 @@ bg:
date: "Дата" date: "Дата"
default_columns: "Колони по подразбиране" default_columns: "Колони по подразбиране"
description: "Описание" description: "Описание"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Показване на суми" display_sums: "Показване на суми"
due_date: "Finish date" due_date: "Finish date"
estimated_hours: "Очаквано време" estimated_hours: "Очаквано време"

@ -740,6 +740,9 @@ ca:
date: "Data" date: "Data"
default_columns: "Columnes predeterminades" default_columns: "Columnes predeterminades"
description: "Descripció" description: "Descripció"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Mostra les sumes" display_sums: "Mostra les sumes"
due_date: "Finish date" due_date: "Finish date"
estimated_hours: "Temps estimat" estimated_hours: "Temps estimat"

@ -748,6 +748,9 @@ cs:
date: "Datum" date: "Datum"
default_columns: "Výchozí sloupce" default_columns: "Výchozí sloupce"
description: "Popis" description: "Popis"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Zobrazit součty" display_sums: "Zobrazit součty"
due_date: "Datum dokončení" due_date: "Datum dokončení"
estimated_hours: "Odhadovaný čas" estimated_hours: "Odhadovaný čas"

@ -740,6 +740,9 @@ da:
date: "Dato" date: "Dato"
default_columns: "Forudvalgte kolonner" default_columns: "Forudvalgte kolonner"
description: "Beskrivelse" description: "Beskrivelse"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Vis totaler" display_sums: "Vis totaler"
due_date: "Finish date" due_date: "Finish date"
estimated_hours: "Anslået tid" estimated_hours: "Anslået tid"

@ -115,7 +115,7 @@ de:
filter_string: | filter_string: |
Fügen Sie einen optionalen RFC4515 Filter hinzu, um die zu findenden Benutzer im LDAP weiter einschränken zu können. Dieser Fillter wird für die Authentifizierung und Gruppensynchronisierung verwendet. Fügen Sie einen optionalen RFC4515 Filter hinzu, um die zu findenden Benutzer im LDAP weiter einschränken zu können. Dieser Fillter wird für die Authentifizierung und Gruppensynchronisierung verwendet.
filter_string_concat: | filter_string_concat: |
OpenProject filtert immer nach dem Login-Attribut des Benutzers filtern, um einen LDAP-Eintrag zu identifizieren. Wenn Sie hier einen Filter angeben, wird OpenProject filtert immer nach dem Login-Attribut des Benutzers, um einen LDAP-Eintrag zu identifizieren. Wenn Sie hier einen Filter angeben, wird
mit einem 'UND' verbunden. Standardmäßig wird ein Catch-All-Filter (objectClass=*) verwendet. mit einem 'UND' verbunden. Standardmäßig wird ein Catch-All-Filter (objectClass=*) verwendet.
onthefly_register: | onthefly_register: |
Wenn Sie dieses Häkchen setzen, erstellt OpenProject automatisch neue Benutzer aus ihren zugehörigen LDAP-Einträgen, wenn sie sich zuerst mit OpenProject anmelden. Wenn Sie dieses Häkchen setzen, erstellt OpenProject automatisch neue Benutzer aus ihren zugehörigen LDAP-Einträgen, wenn sie sich zuerst mit OpenProject anmelden.
@ -735,6 +735,9 @@ de:
date: "Datum" date: "Datum"
default_columns: "Standard-Spalten" default_columns: "Standard-Spalten"
description: "Beschreibung" description: "Beschreibung"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Summen anzeigen" display_sums: "Summen anzeigen"
due_date: "Endtermin" due_date: "Endtermin"
estimated_hours: "Geschätzter Aufwand" estimated_hours: "Geschätzter Aufwand"

@ -737,6 +737,9 @@ el:
date: "Ημερομηνία" date: "Ημερομηνία"
default_columns: "Προεπιλεγμένες στήλες" default_columns: "Προεπιλεγμένες στήλες"
description: "Περιγραφή" description: "Περιγραφή"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Εμφάνιση Αθροισμάτων" display_sums: "Εμφάνιση Αθροισμάτων"
due_date: "Ημερομηνία λήξης" due_date: "Ημερομηνία λήξης"
estimated_hours: "Εκτιμώμενος χρόνος" estimated_hours: "Εκτιμώμενος χρόνος"

@ -78,7 +78,7 @@ es:
is_active: mostrado actualmente is_active: mostrado actualmente
is_inactive: no mostrado actualmente is_inactive: no mostrado actualmente
attribute_help_texts: attribute_help_texts:
note_public: 'Any text and images you add to this field is publically visible to all logged in users!' note_public: '¡Cualquier texto e imágenes que añadas a este campo es visible públicamente para todos los usuarios conectados!'
text_overview: 'En esta vista, puede crear textos de ayuda personalizados para la vista de atributos. Después de definir estos textos, se pueden mostrar al hacer clic en el icono de ayuda junto al atributo al que pertenezcan.' text_overview: 'En esta vista, puede crear textos de ayuda personalizados para la vista de atributos. Después de definir estos textos, se pueden mostrar al hacer clic en el icono de ayuda junto al atributo al que pertenezcan.'
label_plural: 'Textos de ayuda para atributos' label_plural: 'Textos de ayuda para atributos'
show_preview: 'Vista previa del texto' show_preview: 'Vista previa del texto'
@ -90,15 +90,15 @@ es:
no_results_content_text: Crear un nuevo modo de autenticación no_results_content_text: Crear un nuevo modo de autenticación
background_jobs: background_jobs:
status: status:
error_requeue: "Job experienced an error but is retrying. The error was: %{message}" error_requeue: "El trabajo experimentó un error pero se está reintentando. El error fue: %{message}"
cancelled_due_to: "Job was cancelled due to error: %{message}" cancelled_due_to: "El trabajo ha sido cancelado debido al error: %{message}"
ldap_auth_sources: ldap_auth_sources:
technical_warning_html: | technical_warning_html: |
Este formulario LDAP requiere conocimientos técnicos de su configuración de LDAP / activiar directorio <br/> Este formulario LDAP requiere conocimientos técnicos para la configuración de su LDAP / Directorio Activo <br/>
<a href="https://www.openproject.org/help/administration/manage-ldap-authentication/"> Visite nuestra documentación para obtener instrucciones detalladas. <a href="https://www.openproject.org/help/administration/manage-ldap-authentication/"> Visite nuestra documentación para obtener instrucciones detalladas.
attribute_texts: attribute_texts:
name: Nombre arbitrario de la conexión LDAP name: Nombre arbitrario de la conexión LDAP
host: Nombre del anfitrion LDAP o dirección IP host: Nombre del host LDAP o dirección IP
login_map: La clave de atributo en LDAP que se utiliza para identificar el inicio de sesión único del usuario. Por lo general, esto será `uid` o`samAccountName`. login_map: La clave de atributo en LDAP que se utiliza para identificar el inicio de sesión único del usuario. Por lo general, esto será `uid` o`samAccountName`.
generic_map: La clave de atributo en LDAP que está asignada al proyecto abierto `%{attribute}` atributo generic_map: La clave de atributo en LDAP que está asignada al proyecto abierto `%{attribute}` atributo
admin_map_html: "Opcional: la clave de atributo en LDAP que <strong> si esta presente </strong> marca al usuario del proyecto abierto como administrador. Deje en blanco cuando tenga dudas." admin_map_html: "Opcional: la clave de atributo en LDAP que <strong> si esta presente </strong> marca al usuario del proyecto abierto como administrador. Deje en blanco cuando tenga dudas."
@ -280,7 +280,7 @@ es:
overview: overview:
no_results_title_text: Actualmente no hay paquetes de trabajo asignados a esta versión. no_results_title_text: Actualmente no hay paquetes de trabajo asignados a esta versión.
wiki: wiki:
page_not_editable_index: The requested page does not (yet) exist. You have been redirected to the index of all wiki pages. page_not_editable_index: La página solicitada no existe (todavía). Has sido redirigido al inicio las páginas de la wiki.
no_results_title_text: Actualmente no hay paginas de wiki. no_results_title_text: Actualmente no hay paginas de wiki.
index: index:
no_results_content_text: Añadir una nueva página wiki no_results_content_text: Añadir una nueva página wiki
@ -417,7 +417,7 @@ es:
types: "Tipos" types: "Tipos"
versions: "Versiones" versions: "Versiones"
work_packages: "Paquetes de trabajo" work_packages: "Paquetes de trabajo"
templated: 'Template project' templated: 'Plantilla del proyecto'
projects/status: projects/status:
code: 'Estado' code: 'Estado'
explanation: 'Descripción del estado' explanation: 'Descripción del estado'
@ -489,7 +489,7 @@ es:
parent_work_package: "Padre" parent_work_package: "Padre"
priority: "Prioridad" priority: "Prioridad"
progress: "Progreso (%)" progress: "Progreso (%)"
schedule_manually: "Manual scheduling" schedule_manually: "Programación manual"
spent_hours: "Tiempo empleado" spent_hours: "Tiempo empleado"
spent_time: "Tiempo empleado" spent_time: "Tiempo empleado"
subproject: "Subproyecto" subproject: "Subproyecto"
@ -737,6 +737,9 @@ es:
date: "Fecha" date: "Fecha"
default_columns: "Columnas predeterminadas" default_columns: "Columnas predeterminadas"
description: "Descripción" description: "Descripción"
derived_due_date: "Fecha final derivada"
derived_estimated_time: "Tiempo estimado derivado"
derived_start_date: "Fecha de comienzo deseada"
display_sums: "Mostrar sumas" display_sums: "Mostrar sumas"
due_date: "Fecha de finalización" due_date: "Fecha de finalización"
estimated_hours: "Tiempo estimado" estimated_hours: "Tiempo estimado"
@ -887,7 +890,7 @@ es:
- "Oct" - "Oct"
- "Nov" - "Nov"
- "Dec" - "Dec"
abbr_week: 'Wk' abbr_week: 'Sem'
day_names: day_names:
- "Domingo" - "Domingo"
- "Lunes" - "Lunes"
@ -1123,8 +1126,8 @@ es:
work_package_edit: 'Paquete de trabajo editado' work_package_edit: 'Paquete de trabajo editado'
work_package_note: 'Nota de paquete de trabajo añadido' work_package_note: 'Nota de paquete de trabajo añadido'
export: export:
your_work_packages_export: "Your work packages export" your_work_packages_export: "Exportar paquetes de trabajo"
succeeded: "The export has completed successfully." succeeded: "La exportación se ha completado correctamente."
format: format:
atom: "Atomo" atom: "Atomo"
csv: "CSV" csv: "CSV"
@ -1762,16 +1765,16 @@ es:
mail_body_account_information: "Información de su cuenta" mail_body_account_information: "Información de su cuenta"
mail_body_account_information_external: "Puede usar su %{value} cuenta para ingresar." mail_body_account_information_external: "Puede usar su %{value} cuenta para ingresar."
mail_body_lost_password: "Para cambiar su contraseña, haga clic en el siguiente enlace:" mail_body_lost_password: "Para cambiar su contraseña, haga clic en el siguiente enlace:"
mail_body_register: "Welcome to OpenProject. Please activate your account by clicking on this link:" mail_body_register: "Bienvenido a OpenProject. Por favor, active su cuenta haciendo clic en este enlace:"
mail_body_register_header_title: "Project member invitation email" mail_body_register_header_title: "Correo electrónico de invitación al miembro del proyecto"
mail_body_register_user: "Dear %{name}, " mail_body_register_user: "Estimado/a %{name},"
mail_body_register_links_html: | mail_body_register_links_html: |
Please feel free to browse our youtube channel (%{youtube_link}) where we provide a webinar (%{webinar_link}) Por favor, no dude en navegar por nuestro canal de youtube (%{youtube_link}) donde proporcionamos un webinar (%{webinar_link})
and “Get started” videos (%{get_started_link}) to make your first steps in OpenProject as easy as possible. y videos "Get started" (%{get_started_link}) para hacer que sus primeros pasos en OpenProject sean lo más fáciles posible.
<br /> <br />
If you have any further questions, consult our documentation (%{documentation_link}) or contact us (%{contact_us_link}). Si tiene más preguntas, consulte nuestra documentación (%{documentation_link}) o póngase en contacto con nosotros (%{contact_us_link}).
mail_body_register_closing: "Your OpenProject team" mail_body_register_closing: "Tu equipo de OpenProject"
mail_body_register_ending: "Stay connected! Kind regards," mail_body_register_ending: "¡Mantente conectado! Saludos,"
mail_body_reminder: "%{count} paquete(s) de trabajo que le fueron asignados vencen en los próximos %{days}:" mail_body_reminder: "%{count} paquete(s) de trabajo que le fueron asignados vencen en los próximos %{days}:"
mail_body_group_reminder: "%{count} paquete(s) de trabajo asignado(s) al grupo “%{group}” vencerán en los próximos %{days} días:" mail_body_group_reminder: "%{count} paquete(s) de trabajo asignado(s) al grupo “%{group}” vencerán en los próximos %{days} días:"
mail_body_wiki_content_added: "La página wiki de '%{id}' ha sido añadida por %{author}." mail_body_wiki_content_added: "La página wiki de '%{id}' ha sido añadida por %{author}."
@ -1927,7 +1930,7 @@ es:
permission_manage_project_activities: "Gestionar actividades del proyecto" permission_manage_project_activities: "Gestionar actividades del proyecto"
permission_manage_public_queries: "Administrar vistas públicas" permission_manage_public_queries: "Administrar vistas públicas"
permission_manage_repository: "Gestionar repositorio" permission_manage_repository: "Gestionar repositorio"
permission_manage_subtasks: "Manage work package hierarchies" permission_manage_subtasks: "Administrar jerarquías de paquetes de trabajo"
permission_manage_versions: "Administrar versiones" permission_manage_versions: "Administrar versiones"
permission_manage_wiki: "Administrar wiki" permission_manage_wiki: "Administrar wiki"
permission_manage_wiki_menu: "Administrar menú wiki" permission_manage_wiki_menu: "Administrar menú wiki"
@ -1964,10 +1967,10 @@ es:
title: Cambiar el identificador de proyecto title: Cambiar el identificador de proyecto
template: template:
copying: > copying: >
Your project is being created from the selected template project. You will be notified by mail as soon as the project is available. Tu proyecto está siendo creado a partir de la plantilla seleccionada. Serás notificado por correo electrónico tan pronto como el proyecto esté disponible.
use_template: 'Use template' use_template: 'Usar plantilla'
make_template: 'Set as template' make_template: 'Establecer como plantilla'
remove_from_templates: 'Remove from templates' remove_from_templates: 'Eliminar de plantillas'
archive: archive:
are_you_sure: "¿Está seguro que desea archivar el proyecto '%{name}'?" are_you_sure: "¿Está seguro que desea archivar el proyecto '%{name}'?"
archived: "Archivado" archived: "Archivado"
@ -1989,8 +1992,8 @@ es:
assigned_to_role: "Asignación de roles" assigned_to_role: "Asignación de roles"
member_of_group: "Asignación de grupo" member_of_group: "Asignación de grupo"
assignee_or_group: "Grupo al que pertenece o al que está asignado" assignee_or_group: "Grupo al que pertenece o al que está asignado"
subproject_id: "Including Subproject" subproject_id: "Incluyendo Subproyecto"
only_subproject_id: "Only subproject" only_subproject_id: "Sólo subproyecto"
name_or_identifier: "Nombre o identificador" name_or_identifier: "Nombre o identificador"
repositories: repositories:
at_identifier: 'en %{identifier}' at_identifier: 'en %{identifier}'
@ -2096,8 +2099,8 @@ es:
warnings: warnings:
cannot_annotate: "No se pueden realizar notas sobre este fichero." cannot_annotate: "No se pueden realizar notas sobre este fichero."
scheduling: scheduling:
activated: 'activated' activated: 'Habilitado'
deactivated: 'deactivated' deactivated: 'deshabilitado'
search_input_placeholder: "Buscar..." search_input_placeholder: "Buscar..."
setting_email_delivery_method: "Método de envío de correo electrónico" setting_email_delivery_method: "Método de envío de correo electrónico"
setting_sendmail_location: "Ubicación del ejecutable de sendmail" setting_sendmail_location: "Ubicación del ejecutable de sendmail"

@ -740,6 +740,9 @@ fi:
date: "Päivämäärä" date: "Päivämäärä"
default_columns: "Oletussarakkeet" default_columns: "Oletussarakkeet"
description: "Kuvaus" description: "Kuvaus"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Näytä summat" display_sums: "Näytä summat"
due_date: "Päättymispäivä" due_date: "Päättymispäivä"
estimated_hours: "Työmääräarvio" estimated_hours: "Työmääräarvio"

@ -740,6 +740,9 @@ fil:
date: "Petsa" date: "Petsa"
default_columns: "I-default ang mga hanay" default_columns: "I-default ang mga hanay"
description: "Deskripsyon" description: "Deskripsyon"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Ipakita ang mga sum" display_sums: "Ipakita ang mga sum"
due_date: "Finish date" due_date: "Finish date"
estimated_hours: "Tinantyang oras" estimated_hours: "Tinantyang oras"

@ -739,6 +739,9 @@ fr:
date: "date" date: "date"
default_columns: "Colonnes par défaut" default_columns: "Colonnes par défaut"
description: "Description" description: "Description"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Afficher les sommes" display_sums: "Afficher les sommes"
due_date: "Date de fin" due_date: "Date de fin"
estimated_hours: "Durée estimée" estimated_hours: "Durée estimée"

@ -744,6 +744,9 @@ hr:
date: "Datum" date: "Datum"
default_columns: "Zadani stupci" default_columns: "Zadani stupci"
description: "Opis" description: "Opis"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Prikaži iznose" display_sums: "Prikaži iznose"
due_date: "Finish date" due_date: "Finish date"
estimated_hours: "Predviđeno vrijeme" estimated_hours: "Predviđeno vrijeme"

@ -737,6 +737,9 @@ hu:
date: "dátum" date: "dátum"
default_columns: "Alapértelmezett oszlopok" default_columns: "Alapértelmezett oszlopok"
description: "Leírás" description: "Leírás"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Megjelenitendő összegek" display_sums: "Megjelenitendő összegek"
due_date: "Befejezési dátum" due_date: "Befejezési dátum"
estimated_hours: "Becsült idő (óra)" estimated_hours: "Becsült idő (óra)"

@ -735,6 +735,9 @@ id:
date: "Tanggal" date: "Tanggal"
default_columns: "Kolom default" default_columns: "Kolom default"
description: "Deskripsi" description: "Deskripsi"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Tampilkan jumlah" display_sums: "Tampilkan jumlah"
due_date: "Finish date" due_date: "Finish date"
estimated_hours: "Estimasi Waktu" estimated_hours: "Estimasi Waktu"

@ -736,6 +736,9 @@ it:
date: "Data" date: "Data"
default_columns: "Colonne predefinite" default_columns: "Colonne predefinite"
description: "Descrizione" description: "Descrizione"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Visualizza somme" display_sums: "Visualizza somme"
due_date: "Data di fine" due_date: "Data di fine"
estimated_hours: "Tempo stimato" estimated_hours: "Tempo stimato"

@ -732,6 +732,9 @@ ja:
date: "日付" date: "日付"
default_columns: "既定の列" default_columns: "既定の列"
description: "説明" description: "説明"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "合計を表示" display_sums: "合計を表示"
due_date: "終了日" due_date: "終了日"
estimated_hours: "予定工数" estimated_hours: "予定工数"

@ -168,7 +168,7 @@ es:
trial: trial:
confirmation: "Confirmación de dirección de correo electrónico" confirmation: "Confirmación de dirección de correo electrónico"
confirmation_info: > confirmation_info: >
We sent you an email on %{date} to %{email}. Please check your inbox and click the confirmation link provided to start your 14 days trial. Le hemos enviado un correo electrónico a %{email} el %{date}. Por favor, compruebe su bandeja de entrada y haga clic en el enlace de confirmación que le hemos enviado para comenzar con su prueba gratuita de 14 días.
form: form:
general_consent: > general_consent: >
Estoy de acuerdo con los <a target="_blank" href="%{link_terms}">Términos del servicio</a> y la <a target="_blank" href="%{link_privacy}">Política de privacidad</a>. Estoy de acuerdo con los <a target="_blank" href="%{link_terms}">Términos del servicio</a> y la <a target="_blank" href="%{link_privacy}">Política de privacidad</a>.
@ -398,7 +398,7 @@ es:
label_sum_for: "Suma para" label_sum_for: "Suma para"
label_subject: "Asunto" label_subject: "Asunto"
label_this_week: "esta semana" label_this_week: "esta semana"
label_today: "Today" label_today: "Hoy"
label_time_entry_plural: "Tiempo empleado" label_time_entry_plural: "Tiempo empleado"
label_up: "Arriba" label_up: "Arriba"
label_user_plural: "Usuarios" label_user_plural: "Usuarios"
@ -585,8 +585,8 @@ es:
field_value_enter_prompt: "Introduzca un valor para '%{field}'" field_value_enter_prompt: "Introduzca un valor para '%{field}'"
project_menu_details: "Detalles" project_menu_details: "Detalles"
scheduling: scheduling:
manual: 'Manual scheduling' manual: 'Programación manual'
automatic: 'Automatic scheduling' automatic: 'Programación automática'
sort: sort:
sorted_asc: 'Orden ascendiente aplicado ' sorted_asc: 'Orden ascendiente aplicado '
sorted_dsc: 'Orden descendiente aplicado ' sorted_dsc: 'Orden descendiente aplicado '
@ -799,8 +799,8 @@ es:
duplicate_query_title: "El nombre de la vista ya existe. ¿Quiere cambiarlo de todos modos?" duplicate_query_title: "El nombre de la vista ya existe. ¿Quiere cambiarlo de todos modos?"
text_no_results: "No se encontraron vistas que coincidan." text_no_results: "No se encontraron vistas que coincidan."
scheduling: scheduling:
is_parent: "The dates of this work package are automatically deduced from its children. Activate 'Manual scheduling' to set the dates." is_parent: "Las fechas de este paquete de trabajo son deducidas automáticamente de sus hijos. Active 'Programación manual' para establecer las fechas."
is_switched_from_manual_to_automatic: "The dates of this work package may need to be recalculated after switching from manual to automatic scheduling due to relationships with other work packages." is_switched_from_manual_to_automatic: "Las fechas de este paquete de trabajo pueden necesitar ser recalculadas después de pasar de programación manual a programación automática debido a las relaciones con otros paquetes de trabajo."
table: table:
configure_button: 'Configurar tabla de paquetes de trabajo' configure_button: 'Configurar tabla de paquetes de trabajo'
summary: "Tabla con filas de paquetes de trabajo y columnas con sus atributos." summary: "Tabla con filas de paquetes de trabajo y columnas con sus atributos."
@ -890,8 +890,8 @@ es:
confirm_deletion_children: "Reconozco que TODOS los descendientes de los paquetes de trabajo enumerados se eliminarán recursivamente." confirm_deletion_children: "Reconozco que TODOS los descendientes de los paquetes de trabajo enumerados se eliminarán recursivamente."
deletes_children: "También se eliminarán de forma recursiva todos los paquetes de trabajo secundarios y sus descendientes." deletes_children: "También se eliminarán de forma recursiva todos los paquetes de trabajo secundarios y sus descendientes."
destroy_time_entry: destroy_time_entry:
title: "Confirm deletion of time entry" title: "Confirmar la eliminación de la entrada de tiempo"
text: "Are you sure you want to delete the following time entry?" text: "¿Realmente quiere eliminar la siguiente entrada de tiempo?"
notice_no_results_to_display: "No se pueden mostrar resultados visibles." notice_no_results_to_display: "No se pueden mostrar resultados visibles."
notice_successful_create: "Creación exitosa." notice_successful_create: "Creación exitosa."
notice_successful_delete: "Eliminado con éxito." notice_successful_delete: "Eliminado con éxito."

@ -735,6 +735,9 @@ ko:
date: "날짜" date: "날짜"
default_columns: "기본 칼럼" default_columns: "기본 칼럼"
description: "설명" description: "설명"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "합계 표시" display_sums: "합계 표시"
due_date: "완료 날짜" due_date: "완료 날짜"
estimated_hours: "예상된 시간" estimated_hours: "예상된 시간"

@ -743,6 +743,9 @@ lt:
date: "Data" date: "Data"
default_columns: "Numatytieji stulpeliai" default_columns: "Numatytieji stulpeliai"
description: "Aprašymas" description: "Aprašymas"
derived_due_date: "Išvestinė pabaigos data"
derived_estimated_time: "Išvestinis numatytas laikas"
derived_start_date: "Išvestinė pradžios data"
display_sums: "Rodyti suvestines" display_sums: "Rodyti suvestines"
due_date: "Pabaigos data" due_date: "Pabaigos data"
estimated_hours: "Numatyta trukmė" estimated_hours: "Numatyta trukmė"

@ -740,6 +740,9 @@ nl:
date: "Datum" date: "Datum"
default_columns: "Standaardkolommen" default_columns: "Standaardkolommen"
description: "Omschrijving" description: "Omschrijving"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Bedragen weergeven" display_sums: "Bedragen weergeven"
due_date: "Einddatum" due_date: "Einddatum"
estimated_hours: "Geschatte tijd" estimated_hours: "Geschatte tijd"

@ -740,6 +740,9 @@
date: "Dato" date: "Dato"
default_columns: "Standardkolonner" default_columns: "Standardkolonner"
description: "Beskrivelse" description: "Beskrivelse"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Vis summer" display_sums: "Vis summer"
due_date: "Sluttdato" due_date: "Sluttdato"
estimated_hours: "Tidsestimat" estimated_hours: "Tidsestimat"

@ -744,6 +744,9 @@ pl:
date: "Data" date: "Data"
default_columns: "Domyślne kolumny" default_columns: "Domyślne kolumny"
description: "Opis" description: "Opis"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Wyświetl sumy" display_sums: "Wyświetl sumy"
due_date: "Data zakończenia" due_date: "Data zakończenia"
estimated_hours: "Szacowany czas" estimated_hours: "Szacowany czas"

@ -738,6 +738,9 @@ pt:
date: "Data" date: "Data"
default_columns: "Colunas padrão" default_columns: "Colunas padrão"
description: "Descrição" description: "Descrição"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Mostrar somas" display_sums: "Mostrar somas"
due_date: "Data de conclusão" due_date: "Data de conclusão"
estimated_hours: "Tempo estimado" estimated_hours: "Tempo estimado"

@ -744,6 +744,9 @@ ro:
date: "Dată" date: "Dată"
default_columns: "Coloane implicite" default_columns: "Coloane implicite"
description: "Descriere" description: "Descriere"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Afişare totaluri" display_sums: "Afişare totaluri"
due_date: "Finish date" due_date: "Finish date"
estimated_hours: "Durata estimată" estimated_hours: "Durata estimată"

@ -747,6 +747,9 @@ ru:
date: "Дата" date: "Дата"
default_columns: "Столбцы по умолчанию" default_columns: "Столбцы по умолчанию"
description: "Описание" description: "Описание"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Отображение суммы" display_sums: "Отображение суммы"
due_date: "Дата окончания" due_date: "Дата окончания"
estimated_hours: "Предполагаемое время" estimated_hours: "Предполагаемое время"

@ -748,6 +748,9 @@ sk:
date: "Dátum" date: "Dátum"
default_columns: "Predvolené stĺpce" default_columns: "Predvolené stĺpce"
description: "Popis" description: "Popis"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Zobraziť súčty" display_sums: "Zobraziť súčty"
due_date: "Dátum dokončenia" due_date: "Dátum dokončenia"
estimated_hours: "Predpokladaný čas" estimated_hours: "Predpokladaný čas"

@ -746,6 +746,9 @@ sl:
date: "Datum" date: "Datum"
default_columns: "Privzeti stolpci" default_columns: "Privzeti stolpci"
description: "Opis" description: "Opis"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Prikaži vsote" display_sums: "Prikaži vsote"
due_date: "Končni datum" due_date: "Končni datum"
estimated_hours: "Predvideni čas" estimated_hours: "Predvideni čas"

@ -739,6 +739,9 @@ sv:
date: "Datum" date: "Datum"
default_columns: "Standardkolumnerna" default_columns: "Standardkolumnerna"
description: "Beskrivning" description: "Beskrivning"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Visa summor" display_sums: "Visa summor"
due_date: "Slutdatum" due_date: "Slutdatum"
estimated_hours: "Beräknad tid" estimated_hours: "Beräknad tid"

@ -740,6 +740,9 @@ tr:
date: "Tarih" date: "Tarih"
default_columns: "Varsayılan sütunlar" default_columns: "Varsayılan sütunlar"
description: "Açıklama" description: "Açıklama"
derived_due_date: "Türetilmiş bitiş tarihi"
derived_estimated_time: "Türetilmiş tahmini süre"
derived_start_date: "Türetilmiş başlangıç tarihi"
display_sums: "Toplamları görüntüle" display_sums: "Toplamları görüntüle"
due_date: "Bitiş tarihi" due_date: "Bitiş tarihi"
estimated_hours: "Tahmini süre" estimated_hours: "Tahmini süre"

@ -748,6 +748,9 @@ uk:
date: "Дата" date: "Дата"
default_columns: "Типові колонки" default_columns: "Типові колонки"
description: "Опис" description: "Опис"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Відображати суми" display_sums: "Відображати суми"
due_date: "Дата закінчення" due_date: "Дата закінчення"
estimated_hours: "Час (приблизно)" estimated_hours: "Час (приблизно)"

@ -738,6 +738,9 @@ vi:
date: "Ngày" date: "Ngày"
default_columns: "Cột mặc định" default_columns: "Cột mặc định"
description: "Mô tả" description: "Mô tả"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Hiển thị tổng" display_sums: "Hiển thị tổng"
due_date: "Finish date" due_date: "Finish date"
estimated_hours: "Thời gian dự kiến" estimated_hours: "Thời gian dự kiến"

@ -731,6 +731,9 @@ zh-CN:
date: "日期" date: "日期"
default_columns: "默认的列" default_columns: "默认的列"
description: "描述" description: "描述"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "显示汇总" display_sums: "显示汇总"
due_date: "完成日期" due_date: "完成日期"
estimated_hours: "估计的时间" estimated_hours: "估计的时间"

@ -736,6 +736,9 @@ zh-TW:
date: "日期" date: "日期"
default_columns: "預設欄" default_columns: "預設欄"
description: "說明" description: "說明"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "顯示加總" display_sums: "顯示加總"
due_date: "完成日期" due_date: "完成日期"
estimated_hours: "預估時間" estimated_hours: "預估時間"

@ -447,6 +447,7 @@ en:
label_value_derived_from_children: "(value derived from children)" label_value_derived_from_children: "(value derived from children)"
label_warning: "Warning" label_warning: "Warning"
label_work_package: "Work package" label_work_package: "Work package"
label_work_package_parent: "Parent work package"
label_work_package_plural: "Work packages" label_work_package_plural: "Work packages"
label_watch: "Watch" label_watch: "Watch"
label_watch_work_package: "Watch work package" label_watch_work_package: "Watch work package"

@ -72,6 +72,22 @@ Due to flaky test results on Travis (`No output has been received in the last 10
Firefox tests through Selenium are run with Chrome as `--headless` by default. To override this and watch the Chrome instance set the ENV variable `OPENPROJECT_TESTING_NO_HEADLESS=1`. Firefox tests through Selenium are run with Chrome as `--headless` by default. To override this and watch the Chrome instance set the ENV variable `OPENPROJECT_TESTING_NO_HEADLESS=1`.
##### Troubleshooting
```
Failure/Error: raise ActionController::RoutingError, "No route matches [#{env['REQUEST_METHOD']}] #{env['PATH_INFO'].inspect}"
ActionController::RoutingError:
No route matches [GET] "/javascripts/locales/en.js"
```
If you get an error like this when running feature specs it means your assets have not been built.
You can fix this either by accessing a page locally (if the rails server is running) once or by precompiling the assets like this:
```
bundle exec rake assets:precompile
```
### Cucumber ### Cucumber
**Note:** *We do not write new cucumber features. The current plan is to move away from **Note:** *We do not write new cucumber features. The current plan is to move away from

@ -38,6 +38,7 @@ Configuring OpenProject through environment variables is detailed [in this separ
* [`omniauth_direct_login_provider`](#omniauth-direct-login-provider) (default: nil) * [`omniauth_direct_login_provider`](#omniauth-direct-login-provider) (default: nil)
* [`disable_password_login`](#disable-password-login) (default: false) * [`disable_password_login`](#disable-password-login) (default: false)
* [`attachments_storage`](#attachments-storage) (default: file) * [`attachments_storage`](#attachments-storage) (default: file)
* [`direct_uploads`](#direct-uploads) (default: true)
* [`hidden_menu_items`](#hidden-menu-items) (default: {}) * [`hidden_menu_items`](#hidden-menu-items) (default: {})
* [`disabled_modules`](#disabled-modules) (default: []) * [`disabled_modules`](#disabled-modules) (default: [])
* [`blacklisted_routes`](#blacklisted-routes) (default: []) * [`blacklisted_routes`](#blacklisted-routes) (default: [])
@ -172,6 +173,21 @@ In the case of fog you only have to configure everything under `fog`, however. D
to `fog` just yet. Instead leave it as `file`. This is because the current attachments storage is used as the source to `fog` just yet. Instead leave it as `file`. This is because the current attachments storage is used as the source
for the migration. for the migration.
### direct uploads
*default: true*
When using fog attachments uploaded in the frontend will be posted directly
to the cloud rather than going through the OpenProject servers. This allows large attachments to be uploaded
without the need to increase the `client_max_body_size` for the proxy in front of OpenProject.
Also it prevents web processes from being blocked through long uploads.
If, for what ever reason, this is undesirable, you can disable this option.
In that case attachments will be posted as usual to the OpenProject server which then uploads the file
to the remote storage in an extra step.
**Note**: This only works for S3 right now. When using fog with another provider this configuration will be `false`. The same goes for when no fog storage is configured.
### Overriding the help link ### Overriding the help link
You can override the default help menu of OpenProject by specifying a `force_help_link` option to You can override the default help menu of OpenProject by specifying a `force_help_link` option to

@ -48,6 +48,7 @@ import {OpenprojectPluginsModule} from "core-app/modules/plugins/openproject-plu
import {ConfirmFormSubmitController} from "core-components/modals/confirm-form-submit/confirm-form-submit.directive"; import {ConfirmFormSubmitController} from "core-components/modals/confirm-form-submit/confirm-form-submit.directive";
import {ProjectMenuAutocompleteComponent} from "core-components/projects/project-menu-autocomplete/project-menu-autocomplete.component"; import {ProjectMenuAutocompleteComponent} from "core-components/projects/project-menu-autocomplete/project-menu-autocomplete.component";
import {OpenProjectFileUploadService} from "core-components/api/op-file-upload/op-file-upload.service"; import {OpenProjectFileUploadService} from "core-components/api/op-file-upload/op-file-upload.service";
import {OpenProjectDirectFileUploadService} from './components/api/op-file-upload/op-direct-file-upload.service';
import {LinkedPluginsModule} from "core-app/modules/plugins/linked-plugins.module"; import {LinkedPluginsModule} from "core-app/modules/plugins/linked-plugins.module";
import {HookService} from "core-app/modules/plugins/hook-service"; import {HookService} from "core-app/modules/plugins/hook-service";
import {DynamicBootstrapper} from "core-app/globals/dynamic-bootstrapper"; import {DynamicBootstrapper} from "core-app/globals/dynamic-bootstrapper";
@ -143,6 +144,7 @@ import {RevitAddInSettingsButtonService} from "core-app/modules/bim/revit_add_in
{ provide: APP_INITIALIZER, useFactory: initializeServices, deps: [Injector], multi: true }, { provide: APP_INITIALIZER, useFactory: initializeServices, deps: [Injector], multi: true },
PaginationService, PaginationService,
OpenProjectFileUploadService, OpenProjectFileUploadService,
OpenProjectDirectFileUploadService,
// Split view // Split view
CommentService, CommentService,
ConfirmDialogService, ConfirmDialogService,

@ -27,10 +27,12 @@
//++ //++
export type FilterOperator = '='|'!*'|'!'|'~'|'o'|'>t-'|'<>d'|'**'|'ow' ; export type FilterOperator = '='|'!*'|'!'|'~'|'o'|'>t-'|'<>d'|'**'|'ow' ;
export const FalseValue = ['f'];
export const TrueValue = ['t'];
export interface ApiV3FilterValue { export interface ApiV3FilterValue {
operator:FilterOperator; operator:FilterOperator;
values:any; values:unknown[];
} }
export interface ApiV3Filter { export interface ApiV3Filter {
@ -43,7 +45,15 @@ export class ApiV3FilterBuilder {
private filterMap:ApiV3FilterObject = {}; private filterMap:ApiV3FilterObject = {};
public add(name:string, operator:FilterOperator, values:any):this { public add(name:string, operator:FilterOperator, values:unknown[]|boolean):this {
if (values === true) {
values = TrueValue;
}
if (values === false) {
values = FalseValue;
}
this.filterMap[name] = { this.filterMap[name] = {
operator: operator, operator: operator,
values: values values: values

@ -0,0 +1,148 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2020 the OpenProject GmbH
//
// 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 docs/COPYRIGHT.rdoc for more details.
//++
import {Injectable} from "@angular/core";
import {HttpEvent, HttpResponse} from "@angular/common/http";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {from, Observable, of} from "rxjs";
import {share, switchMap} from "rxjs/operators";
import {OpenProjectFileUploadService, UploadBlob, UploadFile, UploadInProgress} from './op-file-upload.service';
interface PrepareUploadResult {
url:string;
form:FormData;
response:any;
}
@Injectable()
export class OpenProjectDirectFileUploadService extends OpenProjectFileUploadService {
/**
* Upload a single file, get an UploadResult observable
* @param {string} url
* @param {UploadFile} file
* @param {string} method
*/
public uploadSingle(url:string, file:UploadFile|UploadBlob, method:string = 'post', responseType:'text'|'json' = 'text') {
const observable = from(this.getDirectUploadFormFrom(url, file))
.pipe(
switchMap(this.uploadToExternal(file, method, responseType)),
share()
);
return [file, observable] as UploadInProgress;
}
private uploadToExternal(file:UploadFile|UploadBlob, method:string, responseType:string):(result:PrepareUploadResult) => Observable<HttpEvent<unknown>> {
return result => {
result.form.append('file', file, file.customName || file.name);
return this
.http
.request<HalResource>(
method,
result.url,
{
body: result.form,
// Observe the response, not the body
observe: 'events',
// This is important as the CORS policy for the bucket is * and you can't use credentals then,
// besides we don't need them here anyway.
withCredentials: false,
responseType: responseType as any,
// Subscribe to progress events. subscribe() will fire multiple times!
reportProgress: true
}
)
.pipe(switchMap(this.finishUpload(result)));
};
}
private finishUpload(result:PrepareUploadResult):(result:HttpEvent<unknown>) => Observable<HttpEvent<unknown>> {
return event => {
if (event instanceof HttpResponse) {
return this
.http
.get(
result.response._links.completeUpload.href,
{
observe: 'response'
}
);
}
// Return as new observable due to switchMap
return of(event);
};
}
public getDirectUploadFormFrom(url:string, file:UploadFile|UploadBlob):Promise<PrepareUploadResult> {
const formData = new FormData();
const metadata = {
description: file.description,
fileName: file.customName || file.name,
fileSize: file.size,
contentType: file.type
};
/*
* @TODO We could calculate the MD5 hash here too and pass that.
* The MD5 hash can be used as the `content-md5` option during the upload to S3 for instance.
* This way S3 can verify the integrity of the file which we currently don't do.
*/
// add the metadata object
formData.append(
'metadata',
JSON.stringify(metadata),
);
const result = this
.http
.request<HalResource>(
"post",
url,
{
body: formData,
withCredentials: true,
responseType: "json" as any
}
)
.toPromise()
.then((res) => {
let form = new FormData();
_.each(res._links.addAttachment.form_fields, (value, key) => {
form.append(key, value);
});
return { url: res._links.addAttachment.href, form: form, response: res };
});
return result;
}
}

@ -27,6 +27,7 @@
//++ //++
import {OpenProjectFileUploadService, UploadFile, UploadResult} from './op-file-upload.service'; import {OpenProjectFileUploadService, UploadFile, UploadResult} from './op-file-upload.service';
import {OpenProjectDirectFileUploadService} from "core-components/api/op-file-upload/op-direct-file-upload.service";
import {HttpClientTestingModule, HttpTestingController} from "@angular/common/http/testing"; import {HttpClientTestingModule, HttpTestingController} from "@angular/common/http/testing";
import {getTestBed, TestBed} from "@angular/core/testing"; import {getTestBed, TestBed} from "@angular/core/testing";
import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service"; import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service";
@ -45,6 +46,7 @@ describe('opFileUpload service', () => {
{provide: States, useValue: new States()}, {provide: States, useValue: new States()},
I18nService, I18nService,
OpenProjectFileUploadService, OpenProjectFileUploadService,
OpenProjectDirectFileUploadService,
HalResourceService HalResourceService
] ]
}); });

@ -48,6 +48,7 @@ import {PathHelperService} from "core-app/modules/common/path-helper/path-helper
import {UIRouterModule} from "@uirouter/angular"; import {UIRouterModule} from "@uirouter/angular";
import {LoadingIndicatorService} from "core-app/modules/common/loading-indicator/loading-indicator.service"; import {LoadingIndicatorService} from "core-app/modules/common/loading-indicator/loading-indicator.service";
import {OpenProjectFileUploadService} from "core-components/api/op-file-upload/op-file-upload.service"; import {OpenProjectFileUploadService} from "core-components/api/op-file-upload/op-file-upload.service";
import {OpenProjectDirectFileUploadService} from "core-components/api/op-file-upload/op-direct-file-upload.service";
import {HookService} from "core-app/modules/plugins/hook-service"; import {HookService} from "core-app/modules/plugins/hook-service";
import {IsolatedQuerySpace} from "core-app/modules/work_packages/query-space/isolated-query-space"; import {IsolatedQuerySpace} from "core-app/modules/work_packages/query-space/isolated-query-space";
import {HalEventsService} from "core-app/modules/hal/services/hal-events.service"; import {HalEventsService} from "core-app/modules/hal/services/hal-events.service";
@ -83,6 +84,7 @@ describe('WorkPackageFilterValues', () => {
CurrentUserService, CurrentUserService,
HookService, HookService,
OpenProjectFileUploadService, OpenProjectFileUploadService,
OpenProjectDirectFileUploadService,
LoadingIndicatorService, LoadingIndicatorService,
HalResourceService, HalResourceService,
NotificationsService, NotificationsService,

@ -3,7 +3,7 @@ import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import { import {
catchError, catchError,
debounceTime, debounceTime,
distinctUntilChanged, distinctUntilChanged, filter, share, shareReplay,
switchMap, switchMap,
takeUntil, takeUntil,
tap tap
@ -37,16 +37,19 @@ export class DebouncedRequestSwitchmap<T, R = HalResource> {
/** /**
* @param handler switch map handler function to output a response observable * @param handler switch map handler function to output a response observable
* @param debounceTime {number} Time to debounce in ms. * @param debounceTime {number} Time to debounce in ms.
* @param preFilterNull {boolean} Whether to exclude null and undefined searches
* @param emptyValue {R} The empty fall back value before first response or on errors * @param emptyValue {R} The empty fall back value before first response or on errors
*/ */
constructor(readonly requestHandler:RequestSwitchmapHandler<T, R[]>, constructor(readonly requestHandler:RequestSwitchmapHandler<T, R[]>,
readonly errorHandler:RequestErrorHandler, readonly errorHandler:RequestErrorHandler,
readonly preFilterNull:boolean = false,
readonly debounceMs = 250) { readonly debounceMs = 250) {
/** Output switchmap observable */ /** Output switchmap observable */
this.output$ = concat( this.output$ = concat(
of([]), of([]),
this.input$.pipe( this.input$.pipe(
filter(val => !preFilterNull || (val !== undefined && val !== null)),
distinctUntilChanged(), distinctUntilChanged(),
debounceTime(debounceMs), debounceTime(debounceMs),
tap((val:T) => { tap((val:T) => {
@ -66,7 +69,8 @@ export class DebouncedRequestSwitchmap<T, R = HalResource> {
this.lastResult = results; this.lastResult = results;
}) })
) )
) ),
shareReplay(1)
) )
); );
} }

@ -35,6 +35,7 @@ import {OpenprojectHalModule} from 'core-app/modules/hal/openproject-hal.module'
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource'; import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {HalResourceService} from 'core-app/modules/hal/services/hal-resource.service'; import {HalResourceService} from 'core-app/modules/hal/services/hal-resource.service';
import {OpenProjectFileUploadService} from 'core-components/api/op-file-upload/op-file-upload.service'; import {OpenProjectFileUploadService} from 'core-components/api/op-file-upload/op-file-upload.service';
import {OpenProjectDirectFileUploadService} from "core-components/api/op-file-upload/op-direct-file-upload.service";
import {SchemaCacheService} from 'core-components/schemas/schema-cache.service'; import {SchemaCacheService} from 'core-components/schemas/schema-cache.service';
import {States} from 'core-components/states.service'; import {States} from 'core-components/states.service';
import {HalResourceNotificationService} from "core-app/modules/hal/services/hal-resource-notification.service"; import {HalResourceNotificationService} from "core-app/modules/hal/services/hal-resource-notification.service";
@ -70,7 +71,8 @@ describe('WorkPackageCache', () => {
{provide: NotificationsService, useValue: {}}, {provide: NotificationsService, useValue: {}},
{provide: HalResourceNotificationService, useValue: {handleRawError: () => false}}, {provide: HalResourceNotificationService, useValue: {handleRawError: () => false}},
{provide: WorkPackageNotificationService, useValue: {}}, {provide: WorkPackageNotificationService, useValue: {}},
{provide: OpenProjectFileUploadService, useValue: {}} {provide: OpenProjectFileUploadService, useValue: {}},
{provide: OpenProjectDirectFileUploadService, useValue: {}},
] ]
}); });

@ -39,11 +39,22 @@ import {BoardActionService} from "core-app/modules/boards/board/board-actions/bo
import {HalResource} from "core-app/modules/hal/resources/hal-resource"; import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {AngularTrackingHelpers} from "core-components/angular/tracking-functions"; import {AngularTrackingHelpers} from "core-components/angular/tracking-functions";
import {CreateAutocompleterComponent} from "core-app/modules/common/autocomplete/create-autocompleter.component"; import {CreateAutocompleterComponent} from "core-app/modules/common/autocomplete/create-autocompleter.component";
import {of} from "rxjs";
import {DebouncedRequestSwitchmap, errorNotificationHandler} from "core-app/helpers/rxjs/debounced-input-switchmap";
import {ValueOption} from "core-app/modules/fields/edit/field-types/select-edit-field.component";
import {HalResourceNotificationService} from "core-app/modules/hal/services/hal-resource-notification.service";
@Component({ @Component({
templateUrl: './add-list-modal.html' templateUrl: './add-list-modal.html'
}) })
export class AddListModalComponent extends OpModalComponent implements OnInit { export class AddListModalComponent extends OpModalComponent implements OnInit {
/** Keep a switchmap for search term and loading state */
public requests = new DebouncedRequestSwitchmap<string, ValueOption>(
(searchTerm:string) => this.actionService.loadAvailable(this.board, this.active, searchTerm),
errorNotificationHandler(this.halNotification),
true
);
public showClose:boolean; public showClose:boolean;
public confirmed = false; public confirmed = false;
@ -57,9 +68,6 @@ export class AddListModalComponent extends OpModalComponent implements OnInit {
/** Action service used by the board */ /** Action service used by the board */
public actionService:BoardActionService; public actionService:BoardActionService;
/** Remaining available values */
public availableValues:HalResource[] = [];
/** The selected attribute */ /** The selected attribute */
public selectedAttribute:HalResource|undefined; public selectedAttribute:HalResource|undefined;
@ -71,8 +79,6 @@ export class AddListModalComponent extends OpModalComponent implements OnInit {
/* Do not close on outside click (because the select option are appended to the body */ /* Do not close on outside click (because the select option are appended to the body */
public closeOnOutsideClick = false; public closeOnOutsideClick = false;
public valuesAvailable:boolean = true;
public warningText:string|undefined; public warningText:string|undefined;
public text:any = { public text:any = {
@ -92,14 +98,22 @@ export class AddListModalComponent extends OpModalComponent implements OnInit {
public referenceOutputs = { public referenceOutputs = {
onCreate: (value:HalResource) => this.onNewActionCreated(value), onCreate: (value:HalResource) => this.onNewActionCreated(value),
onOpen: () => this.requests.input$.next(''),
onChange: (value:HalResource) => this.onModelChange(value), onChange: (value:HalResource) => this.onModelChange(value),
onAfterViewInit: (component:CreateAutocompleterComponent) => component.focusInputField() onAfterViewInit: (component:CreateAutocompleterComponent) => component.focusInputField()
}; };
/** The loaded available values */
availableValues:any;
/** Whether the no results warning is displayed */
showWarning:boolean = false;
constructor(readonly elementRef:ElementRef, constructor(readonly elementRef:ElementRef,
@Inject(OpModalLocalsToken) public locals:OpModalLocalsMap, @Inject(OpModalLocalsToken) public locals:OpModalLocalsMap,
readonly cdRef:ChangeDetectorRef, readonly cdRef:ChangeDetectorRef,
readonly boardActions:BoardActionsRegistryService, readonly boardActions:BoardActionsRegistryService,
readonly halNotification:HalResourceNotificationService,
readonly state:StateService, readonly state:StateService,
readonly boardService:BoardService, readonly boardService:BoardService,
readonly I18n:I18nService) { readonly I18n:I18nService) {
@ -114,19 +128,26 @@ export class AddListModalComponent extends OpModalComponent implements OnInit {
this.active = new Set(this.locals.active as string[]); this.active = new Set(this.locals.active as string[]);
this.actionService = this.boardActions.get(this.board.actionAttribute!); this.actionService = this.boardActions.get(this.board.actionAttribute!);
this.actionService
.getAvailableValues(this.board, this.active)
.then(available => {
this.availableValues = available;
if (this.availableValues.length === 0) {
this.actionService this.actionService
.warningTextWhenNoOptionsAvailable() .warningTextWhenNoOptionsAvailable()
.then((text) => { .then((text) => {
this.warningText = text; this.warningText = text;
this.valuesAvailable = false;
}); });
}
this
.requests
.output$
.pipe(
this.untilDestroyed()
)
.subscribe((values:unknown[]) => {
this.availableValues = values;
this.showWarning = this.requests.lastRequestedValue !== undefined && (values.length === 0);
this.cdRef.detectChanges();
}); });
// Request an empty value to load warning early on
this.requests.input$.next('');
} }
onModelChange(element:HalResource) { onModelChange(element:HalResource) {
@ -147,7 +168,6 @@ export class AddListModalComponent extends OpModalComponent implements OnInit {
} }
onNewActionCreated(newValue:HalResource) { onNewActionCreated(newValue:HalResource) {
this.actionService.cache.clear("New attribute added.");
this.selectedAttribute = newValue; this.selectedAttribute = newValue;
this.create(); this.create();
} }

@ -14,12 +14,20 @@
</div> </div>
<div class="ngdialog-body op-modal--modal-body"> <div class="ngdialog-body op-modal--modal-body">
<div *ngIf="showWarning && warningText"
class="notification-box -warning">
<div class="notification-box--content">
<p [innerHTML]="warningText"></p>
</div>
</div>
<div class="form--field"> <div class="form--field">
<div class="form--field-container"> <div class="form--field-container">
<div class="form--select-container"> <div class="form--select-container">
<label class="form--label" [textContent]="actionService.localizedName"></label> <label class="form--label" [textContent]="actionService.localizedName"></label>
<ndc-dynamic [ndcDynamicComponent]="autocompleterComponent()" <ndc-dynamic [ndcDynamicComponent]="autocompleterComponent()"
[ndcDynamicInputs]="{ availableValues: availableValues, [ndcDynamicInputs]="{ availableValues: availableValues,
typeahead: requests.input$,
appendTo: 'body', appendTo: 'body',
model: '', model: '',
classes: 'new-list--action-select', classes: 'new-list--action-select',
@ -30,13 +38,6 @@
</div> </div>
</div> </div>
<div *ngIf="!valuesAvailable && warningText"
class="notification-box -warning">
<div class="notification-box--content">
<p [innerHTML]="warningText"></p>
</div>
</div>
</div> </div>
<div class="op-modal--modal-footer"> <div class="op-modal--modal-footer">
<button class="button -highlight" <button class="button -highlight"

@ -1,14 +1,13 @@
import {Injectable} from "@angular/core"; import {Injectable} from "@angular/core";
import {BoardActionService} from "core-app/modules/boards/board/board-actions/board-action.service";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {UserResource} from 'core-app/modules/hal/resources/user-resource'; import {UserResource} from 'core-app/modules/hal/resources/user-resource';
import {CollectionResource} from 'core-app/modules/hal/resources/collection-resource'; import {CollectionResource} from 'core-app/modules/hal/resources/collection-resource';
import {AssigneeBoardHeaderComponent} from "core-app/modules/boards/board/board-actions/assignee/assignee-board-header.component"; import {AssigneeBoardHeaderComponent} from "core-app/modules/boards/board/board-actions/assignee/assignee-board-header.component";
import {ProjectResource} from "core-app/modules/hal/resources/project-resource"; import {ProjectResource} from "core-app/modules/hal/resources/project-resource";
import {take} from "rxjs/operators"; import {CachedBoardActionService} from "core-app/modules/boards/board/board-actions/cached-board-action.service";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
@Injectable() @Injectable()
export class BoardAssigneeActionService extends BoardActionService { export class BoardAssigneeActionService extends CachedBoardActionService {
filterName = 'assignee'; filterName = 'assignee';
text = this.I18n.t('js.boards.board_type.action_by_attribute', text = this.I18n.t('js.boards.board_type.action_by_attribute',
@ -49,7 +48,7 @@ export class BoardAssigneeActionService extends BoardActionService {
}); });
} }
protected loadAvailable():Promise<HalResource[]> { protected loadUncached():Promise<HalResource[]> {
return this return this
.apiV3Service .apiV3Service
.projects .projects

@ -12,21 +12,18 @@ import {HalResourceService} from "core-app/modules/hal/services/hal-resource.ser
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service"; import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {CurrentProjectService} from "core-components/projects/current-project.service"; import {CurrentProjectService} from "core-components/projects/current-project.service";
import {Injectable, Injector} from "@angular/core"; import {Injectable, Injector} from "@angular/core";
import {take} from "rxjs/operators"; import {map} from "rxjs/operators";
import {input} from "reactivestates";
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource"; import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
import {IFieldSchema} from "core-app/modules/fields/field.base"; import {IFieldSchema} from "core-app/modules/fields/field.base";
import {WorkPackageChangeset} from "core-components/wp-edit/work-package-changeset"; import {WorkPackageChangeset} from "core-components/wp-edit/work-package-changeset";
import {WorkPackageFilterValues} from "core-components/wp-edit-form/work-package-filter-values"; import {WorkPackageFilterValues} from "core-components/wp-edit-form/work-package-filter-values";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service"; import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {SchemaCacheService} from "core-components/schemas/schema-cache.service"; import {SchemaCacheService} from "core-components/schemas/schema-cache.service";
import {Observable} from "rxjs";
@Injectable() @Injectable()
export abstract class BoardActionService { export abstract class BoardActionService {
// Cache the available values for the duration of the board
readonly cache = input<HalResource[]>();
constructor(readonly injector:Injector, constructor(readonly injector:Injector,
protected boardListsService:BoardListsService, protected boardListsService:BoardListsService,
protected I18n:I18nService, protected I18n:I18nService,
@ -65,33 +62,36 @@ export abstract class BoardActionService {
/** /**
* Returns the current filter value ID if any * Returns the current filter value ID if any
* @param query * @param query
* @returns /api/v3/status/:id if a status filter exists * @returns The id of the resource
*/ */
getActionValueHrefForColumn(query:QueryResource):string|undefined { getActionValueId(query:QueryResource, getHref = false):string|undefined {
const filter = _.find(query.filters, filter => filter.id === this.filterName); const filter = _.find(query.filters, filter => filter.id === this.filterName);
if (filter) { if (!filter) {
return;
}
const value = filter.values[0] as string|HalResource; const value = filter.values[0] as string|HalResource;
return (value instanceof HalResource) ? value.href! : value;
if (value instanceof HalResource) {
return getHref ? value.href! : value.id!;
} }
return; return value;
} }
/** /**
* Returns the current filter value if any * Returns the current filter value if any
* @param query * @param query
* @returns /api/v3/status/:id if a status filter exists * @returns The loaded action reosurce
*/ */
getLoadedActionValue(query:QueryResource):Promise<HalResource|undefined> { getLoadedActionValue(query:QueryResource):Promise<HalResource|undefined> {
const href = this.getActionValueHrefForColumn(query); const id = this.getActionValueId(query);
if (!href) { if (!id) {
return Promise.resolve(undefined); return Promise.resolve(undefined);
} }
return this return this.require(id);
.withLoadedAvailable()
.then(collection => collection.find(resource => resource.href === href));
} }
/** /**
@ -125,13 +125,14 @@ export abstract class BoardActionService {
* Get available values from the active queries * Get available values from the active queries
* *
* @param board The board we're looking at * @param board The board we're looking at
* @param active The active set of values (hrefs or plain values) * @param active The active set of values (resources or plain values)
* @param matching values matching the given name
*/ */
getAvailableValues(board:Board, active:Set<string>):Promise<HalResource[]> { loadAvailable(board:Board, active:Set<string>, matching:string):Observable<HalResource[]> {
return this return this
.withLoadedAvailable() .loadValues(matching)
.then(results => .pipe(
results.filter(item => !active.has(item.id!)) map(items => items.filter(item => !active.has(item.id!)))
); );
} }
@ -218,17 +219,20 @@ export abstract class BoardActionService {
filter.applyDefaultsFromFilters(); filter.applyDefaultsFromFilters();
} }
protected withLoadedAvailable():Promise<HalResource[]> { /**
this.cache.putFromPromiseIfPristine(() => this.loadAvailable()); * Require the given resource to be loaded.
*
return this.cache * @param id
.values$() * @protected
.pipe(take(1)) */
.toPromise(); protected abstract require(id:string):Promise<HalResource>;
}
/** /**
* Load the available values for this action attribute * Load values optionally matching the given name
*
* @param matching
* @protected
*/ */
protected abstract loadAvailable():Promise<HalResource[]>; protected abstract loadValues(matching?:string):Observable<HalResource[]>;
} }

@ -0,0 +1,62 @@
import {Injectable} from "@angular/core";
import {BoardActionService} from "core-app/modules/boards/board/board-actions/board-action.service";
import {input} from "reactivestates";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {Observable} from "rxjs";
import {filter, map, take} from "rxjs/operators";
import {Board} from "core-app/modules/boards/board/board";
@Injectable()
export abstract class CachedBoardActionService extends BoardActionService {
protected cache = input<HalResource[]>();
protected loadValues(matching?:string):Observable<HalResource[]> {
this
.cache
.putFromPromiseIfPristine(() => this.loadUncached());
return this
.cache
.values$()
.pipe(
map(results => {
if (matching) {
return results.filter(resource => resource.name.includes(matching));
} else {
return results;
}
}),
take(1)
);
}
addColumnWithActionAttribute(board:Board, value:HalResource):Promise<Board> {
if (this.cache.value) {
// Add the new value to the cache
let newValue = [...this.cache.value, value];
this.cache.putValue(newValue);
}
return super.addColumnWithActionAttribute(board, value);
}
protected require(id:string):Promise<HalResource> {
this
.cache
.putFromPromiseIfPristine(() => this.loadUncached());
return this
.cache
.values$()
.pipe(
take(1)
)
.toPromise()
.then(results => {
return results.find(resource => resource.id === id)!;
});
}
protected abstract loadUncached():Promise<HalResource[]>;
}

@ -2,9 +2,10 @@ import {Injectable} from "@angular/core";
import {Board} from "core-app/modules/boards/board/board"; import {Board} from "core-app/modules/boards/board/board";
import {StatusResource} from "core-app/modules/hal/resources/status-resource"; import {StatusResource} from "core-app/modules/hal/resources/status-resource";
import {BoardActionService} from "core-app/modules/boards/board/board-actions/board-action.service"; import {BoardActionService} from "core-app/modules/boards/board/board-actions/board-action.service";
import {CachedBoardActionService} from "core-app/modules/boards/board/board-actions/cached-board-action.service";
@Injectable() @Injectable()
export class BoardStatusActionService extends BoardActionService { export class BoardStatusActionService extends CachedBoardActionService {
filterName = 'status'; filterName = 'status';
text = this.I18n.t('js.boards.board_type.action_by_attribute', text = this.I18n.t('js.boards.board_type.action_by_attribute',
@ -20,7 +21,9 @@ export class BoardStatusActionService extends BoardActionService {
} }
public addInitialColumnsForAction(board:Board):Promise<Board> { public addInitialColumnsForAction(board:Board):Promise<Board> {
return this.withLoadedAvailable() return this
.loadValues()
.toPromise()
.then((results) => .then((results) =>
Promise.all<unknown>( Promise.all<unknown>(
results.map((status:StatusResource) => { results.map((status:StatusResource) => {
@ -40,7 +43,7 @@ export class BoardStatusActionService extends BoardActionService {
return Promise.resolve(this.I18n.t('js.boards.add_list_modal.warning.status')); return Promise.resolve(this.I18n.t('js.boards.add_list_modal.warning.status'));
} }
protected loadAvailable():Promise<StatusResource[]> { protected loadUncached():Promise<StatusResource[]> {
return this return this
.apiV3Service .apiV3Service
.statuses .statuses
@ -48,5 +51,4 @@ export class BoardStatusActionService extends BoardActionService {
.toPromise() .toPromise()
.then(collection => collection.elements); .then(collection => collection.elements);
} }
} }

@ -1,30 +1,26 @@
import {Injectable} from "@angular/core"; import {Injectable} from "@angular/core";
import {QueryResource} from "core-app/modules/hal/resources/query-resource"; import {QueryResource} from "core-app/modules/hal/resources/query-resource";
import {BoardActionService} from "core-app/modules/boards/board/board-actions/board-action.service";
import {HalResource} from "core-app/modules/hal/resources/hal-resource"; import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {buildApiV3Filter} from "core-components/api/api-v3/api-v3-filter-builder"; import {buildApiV3Filter} from "core-components/api/api-v3/api-v3-filter-builder";
import {UserResource} from 'core-app/modules/hal/resources/user-resource'; import {UserResource} from 'core-app/modules/hal/resources/user-resource';
import {CollectionResource} from 'core-app/modules/hal/resources/collection-resource'; import {CollectionResource} from 'core-app/modules/hal/resources/collection-resource';
import {input} from "reactivestates"; import {input} from "reactivestates";
import {take} from "rxjs/operators";
import {WorkPackageChangeset} from "core-components/wp-edit/work-package-changeset"; import {WorkPackageChangeset} from "core-components/wp-edit/work-package-changeset";
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource"; import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
import {SubprojectBoardHeaderComponent} from "core-app/modules/boards/board/board-actions/subproject/subproject-board-header.component"; import {SubprojectBoardHeaderComponent} from "core-app/modules/boards/board/board-actions/subproject/subproject-board-header.component";
import {CachedBoardActionService} from "core-app/modules/boards/board/board-actions/cached-board-action.service";
@Injectable() @Injectable()
export class BoardSubprojectActionService extends BoardActionService { export class BoardSubprojectActionService extends CachedBoardActionService {
filterName = 'onlySubproject'; filterName = 'onlySubproject';
text = this.I18n.t('js.boards.board_type.action_by_attribute', text = this.I18n.t('js.boards.board_type.action_by_attribute',
{ attribute: this.I18n.t('js.boards.board_type.action_type.subproject')}) ; { attribute: this.I18n.t('js.boards.board_type.action_type.subproject')}) ;
description = this.I18n.t('js.boards.board_type.action_text', description = this.I18n.t('js.boards.board_type.action_text_subprojects');
{ attribute: this.I18n.t('js.boards.board_type.action_type.subproject')});
icon = 'icon-projects'; icon = 'icon-projects';
private subprojects = input<HalResource[]>();
get localizedName() { get localizedName() {
return this.I18n.t('js.work_packages.properties.subproject'); return this.I18n.t('js.work_packages.properties.subproject');
} }
@ -40,26 +36,19 @@ export class BoardSubprojectActionService extends BoardActionService {
} }
assignToWorkPackage(changeset:WorkPackageChangeset, query:QueryResource) { assignToWorkPackage(changeset:WorkPackageChangeset, query:QueryResource) {
const href = this.getActionValueHrefForColumn(query); const href = this.getActionValueId(query, true);
changeset.setValue('project', { href: href }); changeset.setValue('project', { href: href });
} }
protected loadAvailable():Promise<HalResource[]> { protected loadUncached():Promise<HalResource[]> {
const currentProjectId = this.currentProject.id!; const currentProjectId = this.currentProject.id!;
this.subprojects.putFromPromiseIfPristine(() => return this
this
.apiV3Service .apiV3Service
.projects .projects
.filtered(buildApiV3Filter('ancestor', '=', [currentProjectId])) .filtered(buildApiV3Filter('ancestor', '=', [currentProjectId]))
.get() .get()
.toPromise() .toPromise()
.then((collection:CollectionResource<UserResource>) => collection.elements) .then((collection:CollectionResource<UserResource>) => collection.elements);
);
return this.subprojects
.values$()
.pipe(take(1))
.toPromise();
} }
} }

@ -0,0 +1,62 @@
import {Injectable} from "@angular/core";
import {Board} from "core-app/modules/boards/board/board";
import {StatusResource} from "core-app/modules/hal/resources/status-resource";
import {BoardActionService} from "core-app/modules/boards/board/board-actions/board-action.service";
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {Observable} from "rxjs";
import {map} from "rxjs/operators";
import {ApiV3FilterBuilder, buildApiV3Filter, FalseValue} from "core-components/api/api-v3/api-v3-filter-builder";
import {SubtasksBoardHeaderComponent} from "core-app/modules/boards/board/board-actions/subtasks/subtasks-board-header.component";
@Injectable()
export class BoardSubtasksActionService extends BoardActionService {
filterName = 'parent';
text = this.I18n.t('js.boards.board_type.action_by_attribute',
{ attribute: this.I18n.t('js.boards.board_type.action_type.subtasks')}) ;
description = this.I18n.t('js.boards.board_type.action_text_subtasks');
icon = 'icon-hierarchy';
public get localizedName() {
return this.I18n.t('js.boards.board_type.action_type.subtasks');
}
public headerComponent() {
return SubtasksBoardHeaderComponent;
}
public canMove(workPackage:WorkPackageResource):boolean {
return !!workPackage.changeParent;
}
protected loadValues(matching?:string):Observable<HalResource[]> {
let filters = new ApiV3FilterBuilder();
filters.add('is_milestone', '=', false);
filters.add('project', '=', [this.currentProject.id]);
if (matching) {
filters.add('subjectOrId', '**', [matching]);
}
return this
.apiV3Service
.work_packages
.filtered(filters)
.get()
.pipe(
map(collection => collection.elements)
);
}
protected require(id:string):Promise<HalResource> {
return this
.apiV3Service
.work_packages
.id(id)
.get()
.toPromise();
}
}

@ -0,0 +1,57 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2020 the OpenProject GmbH
//
// 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 docs/COPYRIGHT.rdoc for more details.
//++
import {Component, Input, OnInit} from "@angular/core";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
import {Highlighting} from "core-components/wp-fast-table/builders/highlighting/highlighting.functions";
@Component({
templateUrl: './subtasks-board-header.html',
styleUrls: ['./subtasks-board-header.sass'],
host: { 'class': 'title-container -small' }
})
export class SubtasksBoardHeaderComponent implements OnInit {
@Input() public resource:WorkPackageResource;
text = {
workPackage: this.I18n.t('js.label_work_package_parent')
};
typeHighlightingClass:string;
constructor(readonly pathHelper:PathHelperService,
readonly I18n:I18nService) {
}
ngOnInit() {
this.typeHighlightingClass = Highlighting.inlineClass('type', this.resource.type.id!);
}
}

@ -0,0 +1,14 @@
<div class="subtask-board-header" *ngIf="resource">
<h2 class="editable-toolbar-title--fixed">
<small [textContent]="text.workPackage"></small>
<br/>
<span class="work-package-type"
[ngClass]="typeHighlightingClass"
[textContent]="resource.type.name">
</span>
<a [href]="pathHelper.workPackagePath(resource.idFromLink)"
[textContent]="resource.subjectWithId()"
target="_blank">
</a>
</h2>
</div>

@ -0,0 +1,7 @@
// Override line-height for proper
// display of the h2 + small
.editable-toolbar-title--fixed
line-height: 1 !important
.work-package-type
padding-right: 0.5rem

@ -12,9 +12,10 @@ import {HalResourceNotificationService} from "core-app/modules/hal/services/hal-
import {VersionBoardHeaderComponent} from "core-app/modules/boards/board/board-actions/version/version-board-header.component"; import {VersionBoardHeaderComponent} from "core-app/modules/boards/board/board-actions/version/version-board-header.component";
import {FormResource} from "core-app/modules/hal/resources/form-resource"; import {FormResource} from "core-app/modules/hal/resources/form-resource";
import {InjectField} from "core-app/helpers/angular/inject-field.decorator"; import {InjectField} from "core-app/helpers/angular/inject-field.decorator";
import {CachedBoardActionService} from "core-app/modules/boards/board/board-actions/cached-board-action.service";
@Injectable() @Injectable()
export class BoardVersionActionService extends BoardActionService { export class BoardVersionActionService extends CachedBoardActionService {
@InjectField() state:StateService; @InjectField() state:StateService;
@InjectField() halNotification:HalResourceNotificationService; @InjectField() halNotification:HalResourceNotificationService;
@ -50,7 +51,9 @@ export class BoardVersionActionService extends BoardActionService {
} }
public addInitialColumnsForAction(board:Board):Promise<Board> { public addInitialColumnsForAction(board:Board):Promise<Board> {
return this.withLoadedAvailable() return this
.loadValues()
.toPromise()
.then((results) => { .then((results) => {
return Promise.all<unknown>( return Promise.all<unknown>(
results.map((version:VersionResource) => { results.map((version:VersionResource) => {
@ -105,7 +108,7 @@ export class BoardVersionActionService extends BoardActionService {
return value instanceof VersionResource && value.isOpen(); return value instanceof VersionResource && value.isOpen();
} }
protected loadAvailable():Promise<VersionResource[]> { protected loadUncached():Promise<HalResource[]> {
if (this.currentProject.id === null) { if (this.currentProject.id === null) {
return Promise.resolve([]); return Promise.resolve([]);
} }

@ -334,7 +334,7 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
} }
let actionService = this.actionService!; let actionService = this.actionService!;
const id = actionService.getActionValueHrefForColumn(query); const id = actionService.getActionValueId(query);
// Test if we loaded the resource already // Test if we loaded the resource already
if (this.actionResource && id === this.actionResource.href) { if (this.actionResource && id === this.actionResource.href) {

@ -204,7 +204,7 @@ export class BoardListContainerComponent extends UntilDestroyedMixin implements
const filter = _.find(options.filters, (filter) => !!filter[filterName]); const filter = _.find(options.filters, (filter) => !!filter[filterName]);
if (filter) { if (filter) {
return filter[filterName].values[0]; return filter[filterName].values[0] as any;
} }
}) })
.filter(value => !!value); .filter(value => !!value);

@ -6,6 +6,7 @@ import {BoardVersionActionService} from "core-app/modules/boards/board/board-act
import {QueryUpdatedService} from "core-app/modules/boards/board/query-updated/query-updated.service"; import {QueryUpdatedService} from "core-app/modules/boards/board/query-updated/query-updated.service";
import {BoardAssigneeActionService} from "core-app/modules/boards/board/board-actions/assignee/assignee-action.service"; import {BoardAssigneeActionService} from "core-app/modules/boards/board/board-actions/assignee/assignee-action.service";
import {BoardSubprojectActionService} from "core-app/modules/boards/board/board-actions/subproject/subproject-action.service"; import {BoardSubprojectActionService} from "core-app/modules/boards/board/board-actions/subproject/subproject-action.service";
import {BoardSubtasksActionService} from "core-app/modules/boards/board/board-actions/subtasks/board-subtasks-action.service";
@Component({ @Component({
selector: 'boards-entry', selector: 'boards-entry',
@ -16,6 +17,7 @@ import {BoardSubprojectActionService} from "core-app/modules/boards/board/board-
BoardVersionActionService, BoardVersionActionService,
BoardAssigneeActionService, BoardAssigneeActionService,
BoardSubprojectActionService, BoardSubprojectActionService,
BoardSubtasksActionService,
QueryUpdatedService, QueryUpdatedService,
] ]
}) })
@ -25,14 +27,11 @@ export class BoardsRootComponent {
// Register action services // Register action services
const registry = injector.get(BoardActionsRegistryService); const registry = injector.get(BoardActionsRegistryService);
const statusAction = injector.get(BoardStatusActionService);
const versionAction = injector.get(BoardVersionActionService);
const assigneeAction = injector.get(BoardAssigneeActionService);
const subprojectAction = injector.get(BoardSubprojectActionService);
registry.add('status', statusAction); registry.add('status', injector.get(BoardStatusActionService));
registry.add('assignee', assigneeAction); registry.add('assignee', injector.get(BoardAssigneeActionService));
registry.add('version', versionAction); registry.add('version', injector.get(BoardVersionActionService));
registry.add('subproject', subprojectAction); registry.add('subproject', injector.get(BoardSubprojectActionService));
registry.add('subtasks', injector.get(BoardSubtasksActionService));
} }
} }

@ -53,6 +53,7 @@ import {BoardsMenuButtonComponent} from "core-app/modules/boards/board/toolbar-m
import {AssigneeBoardHeaderComponent} from "core-app/modules/boards/board/board-actions/assignee/assignee-board-header.component"; import {AssigneeBoardHeaderComponent} from "core-app/modules/boards/board/board-actions/assignee/assignee-board-header.component";
import { TileViewComponent } from './tile-view/tile-view.component'; import { TileViewComponent } from './tile-view/tile-view.component';
import {SubprojectBoardHeaderComponent} from "core-app/modules/boards/board/board-actions/subproject/subproject-board-header.component"; import {SubprojectBoardHeaderComponent} from "core-app/modules/boards/board/board-actions/subproject/subproject-board-header.component";
import {SubtasksBoardHeaderComponent} from "core-app/modules/boards/board/board-actions/subtasks/subtasks-board-header.component";
@NgModule({ @NgModule({
imports: [ imports: [
@ -88,8 +89,9 @@ import {SubprojectBoardHeaderComponent} from "core-app/modules/boards/board/boar
BoardFilterComponent, BoardFilterComponent,
VersionBoardHeaderComponent, VersionBoardHeaderComponent,
AssigneeBoardHeaderComponent, AssigneeBoardHeaderComponent,
SubprojectBoardHeaderComponent,
SubtasksBoardHeaderComponent,
TileViewComponent, TileViewComponent,
SubprojectBoardHeaderComponent
] ]
}) })
export class OpenprojectBoardsModule { export class OpenprojectBoardsModule {

@ -9,6 +9,7 @@
[disabled]="disabled" [disabled]="disabled"
[typeahead]="typeahead" [typeahead]="typeahead"
[clearOnBackspace]="false" [clearOnBackspace]="false"
[clearSearchOnAdd]="false"
[appendTo]="appendTo" [appendTo]="appendTo"
[hideSelected]="hideSelected" [hideSelected]="hideSelected"
[id]="id" [id]="id"

@ -65,6 +65,14 @@ export class ConfigurationService {
return this.userPreference('timeZone'); return this.userPreference('timeZone');
} }
public isDirectUploads() {
return !!this.prepareAttachmentURL;
}
public get prepareAttachmentURL() {
return _.get(this.configuration, ['prepareAttachment', 'href']);
}
public get maximumAttachmentFileSize() { public get maximumAttachmentFileSize() {
return this.systemPreference('maximumAttachmentFileSize'); return this.systemPreference('maximumAttachmentFileSize');
} }

@ -26,7 +26,7 @@
// See docs/COPYRIGHT.rdoc for more details. // See docs/COPYRIGHT.rdoc for more details.
//++ //++
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; import {Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild} from '@angular/core';
import {UploadFile, UploadHttpEvent, UploadInProgress} from "core-components/api/op-file-upload/op-file-upload.service"; import {UploadFile, UploadHttpEvent, UploadInProgress} from "core-components/api/op-file-upload/op-file-upload.service";
import {HttpErrorResponse, HttpEventType, HttpProgressEvent} from "@angular/common/http"; import {HttpErrorResponse, HttpEventType, HttpProgressEvent} from "@angular/common/http";
import {I18nService} from "core-app/modules/common/i18n/i18n.service"; import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@ -38,7 +38,8 @@ import {UntilDestroyedMixin} from "core-app/helpers/angular/until-destroyed.mixi
template: ` template: `
<li> <li>
<span class="filename" [textContent]="fileName"></span> <span class="filename" [textContent]="fileName"></span>
<progress [hidden]="completed" max="100" [value]="value">{{value}}%</progress> <progress max="100" value="0" #progressBar></progress>
<p #progressPercentage>0%</p>
<span class="upload-completed" *ngIf="completed || error"> <span class="upload-completed" *ngIf="completed || error">
<op-icon icon-classes="icon-close" *ngIf="error"></op-icon> <op-icon icon-classes="icon-close" *ngIf="error"></op-icon>
<op-icon icon-classes="icon-checkmark" *ngIf="completed"></op-icon> <op-icon icon-classes="icon-checkmark" *ngIf="completed"></op-icon>
@ -51,11 +52,24 @@ export class UploadProgressComponent extends UntilDestroyedMixin implements OnIn
@Output() public onError = new EventEmitter<HttpErrorResponse>(); @Output() public onError = new EventEmitter<HttpErrorResponse>();
@Output() public onSuccess = new EventEmitter<undefined>(); @Output() public onSuccess = new EventEmitter<undefined>();
@ViewChild('progressBar')
progressBar:ElementRef;
@ViewChild('progressPercentage')
progressPercentage:ElementRef;
public file:UploadFile; public file:UploadFile;
public value:number = 0;
public error:boolean = false; public error:boolean = false;
public completed = false; public completed = false;
set value(value:number) {
this.progressBar.nativeElement.value = value;
this.progressPercentage.nativeElement.innerText = `${value}%`;
if (value === 100) {
this.progressBar.nativeElement.style.display = 'none';
}
}
constructor(protected readonly I18n:I18nService) { constructor(protected readonly I18n:I18nService) {
super(); super();
} }

@ -32,8 +32,10 @@ import {OpenProjectFileUploadService, UploadFile} from 'core-components/api/op-f
import {HalResourceNotificationService} from "core-app/modules/hal/services/hal-resource-notification.service"; import {HalResourceNotificationService} from "core-app/modules/hal/services/hal-resource-notification.service";
import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service'; import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service';
import {NotificationsService} from 'core-app/modules/common/notifications/notifications.service'; import {NotificationsService} from 'core-app/modules/common/notifications/notifications.service';
import {ConfigurationService} from 'core-app/modules/common/config/configuration.service';
import {HttpErrorResponse} from "@angular/common/http"; import {HttpErrorResponse} from "@angular/common/http";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service"; import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import { OpenProjectDirectFileUploadService } from 'core-app/components/api/op-file-upload/op-direct-file-upload.service';
type Constructor<T = {}> = new (...args:any[]) => T; type Constructor<T = {}> = new (...args:any[]) => T;
@ -44,8 +46,10 @@ export function Attachable<TBase extends Constructor<HalResource>>(Base:TBase) {
private NotificationsService:NotificationsService; private NotificationsService:NotificationsService;
private halNotification:HalResourceNotificationService; private halNotification:HalResourceNotificationService;
private opFileUpload:OpenProjectFileUploadService; private opFileUpload:OpenProjectFileUploadService;
private opDirectFileUpload:OpenProjectDirectFileUploadService;
private pathHelper:PathHelperService; private pathHelper:PathHelperService;
private apiV3Service:APIV3Service; private apiV3Service:APIV3Service;
private config:ConfigurationService;
/** /**
* Can be used in the mixed in class to disable * Can be used in the mixed in class to disable
@ -168,9 +172,11 @@ export function Attachable<TBase extends Constructor<HalResource>>(Base:TBase) {
} }
private performUpload(files:UploadFile[]) { private performUpload(files:UploadFile[]) {
let href = ''; let href: string = this.directUploadURL || '';
if (this.isNew || !this.id || !this.attachmentsBackend) { if (href) {
return this.opDirectFileUpload.uploadAndMapResponse(href, files);
} else if (this.isNew || !this.id || !this.attachmentsBackend) {
href = this.apiV3Service.attachments.path; href = this.apiV3Service.attachments.path;
} else { } else {
href = this.addAttachment.$link.href; href = this.addAttachment.$link.href;
@ -179,6 +185,18 @@ export function Attachable<TBase extends Constructor<HalResource>>(Base:TBase) {
return this.opFileUpload.uploadAndMapResponse(href, files); return this.opFileUpload.uploadAndMapResponse(href, files);
} }
private get directUploadURL():string|null {
if (this.$links.prepareAttachment) {
return this.$links.prepareAttachment.href;
}
if (this.isNew) {
return this.config.prepareAttachmentURL
} else {
return null;
}
}
private updateState() { private updateState() {
if (this.state) { if (this.state) {
this.state.putValue(this as any); this.state.putValue(this as any);
@ -195,7 +213,12 @@ export function Attachable<TBase extends Constructor<HalResource>>(Base:TBase) {
if (!this.opFileUpload) { if (!this.opFileUpload) {
this.opFileUpload = this.injector.get(OpenProjectFileUploadService); this.opFileUpload = this.injector.get(OpenProjectFileUploadService);
} }
if (!this.opDirectFileUpload) {
this.opDirectFileUpload = this.injector.get(OpenProjectDirectFileUploadService);
}
if (!this.config) {
this.config = this.injector.get(ConfigurationService);
}
if (!this.pathHelper) { if (!this.pathHelper) {
this.pathHelper = this.injector.get(PathHelperService); this.pathHelper = this.injector.get(PathHelperService);
} }

@ -42,6 +42,7 @@ import {ConfigurationService} from 'core-app/modules/common/config/configuration
import {I18nService} from "core-app/modules/common/i18n/i18n.service"; import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {StateService} from "@uirouter/core"; import {StateService} from "@uirouter/core";
import {OpenProjectFileUploadService} from "core-components/api/op-file-upload/op-file-upload.service"; import {OpenProjectFileUploadService} from "core-components/api/op-file-upload/op-file-upload.service";
import {OpenProjectDirectFileUploadService} from "core-components/api/op-file-upload/op-direct-file-upload.service";
import {WorkPackageCreateService} from 'core-app/components/wp-new/wp-create.service'; import {WorkPackageCreateService} from 'core-app/components/wp-new/wp-create.service';
import {WorkPackageNotificationService} from "core-app/modules/work_packages/notifications/work-package-notification.service"; import {WorkPackageNotificationService} from "core-app/modules/work_packages/notifications/work-package-notification.service";
import {WorkPackagesActivityService} from "core-components/wp-single-view-tabs/activity-panel/wp-activity.service"; import {WorkPackagesActivityService} from "core-components/wp-single-view-tabs/activity-panel/wp-activity.service";
@ -76,6 +77,7 @@ describe('WorkPackage', () => {
NotificationsService, NotificationsService,
ConfigurationService, ConfigurationService,
OpenProjectFileUploadService, OpenProjectFileUploadService,
OpenProjectDirectFileUploadService,
LoadingIndicatorService, LoadingIndicatorService,
PathHelperService, PathHelperService,
I18nService, I18nService,

@ -178,18 +178,6 @@ export class WorkPackageBaseResource extends HalResource {
return fieldName === 'description' ? 'full' : 'constrained'; return fieldName === 'description' ? 'full' : 'constrained';
} }
private performUpload(files:UploadFile[]) {
let href = '';
if (this.isNew) {
href = this.apiV3Service.attachments.path;
} else {
href = this.attachments.$href!;
}
return this.opFileUpload.uploadAndMapResponse(href, files);
}
public isParentOf(otherWorkPackage:WorkPackageResource) { public isParentOf(otherWorkPackage:WorkPackageResource) {
return otherWorkPackage.parent?.$links.self.$link.href === this.$links.self.$link.href; return otherWorkPackage.parent?.$links.self.$link.href === this.$links.self.$link.href;
} }

@ -33,8 +33,10 @@ module API
identifier 'NotFound' identifier 'NotFound'
code 404 code 404
def initialize(*) def initialize(*args)
super I18n.t('api_v3.errors.code_404') opts = args.last.is_a?(Hash) ? args.last : {}
super opts[:message] || I18n.t('api_v3.errors.code_404')
end end
end end
end end

@ -43,6 +43,15 @@ module API
} }
end end
link :prepareAttachment do
next unless OpenProject::Configuration.direct_uploads?
{
href: attachments_by_resource + '/prepare',
method: :post
}
end
link :addAttachment, link :addAttachment,
cache_if: -> do cache_if: -> do
represented.attachments_addable?(current_user) represented.attachments_addable?(current_user)

@ -46,6 +46,10 @@ module API
}, },
setter: ->(fragment:, **) { self.description = fragment['raw'] }, setter: ->(fragment:, **) { self.description = fragment['raw'] },
render_nil: true render_nil: true
property :content_type, render_nil: false
property :file_size, render_nil: false
property :digest, render_nil: false
end end
end end
end end

@ -0,0 +1,158 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 the OpenProject GmbH
#
# 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-2017 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'roar/decorator'
require 'roar/json/hal'
module API
module V3
module Attachments
class AttachmentUploadRepresenter < ::API::Decorators::Single
include API::Decorators::DateProperty
include API::Decorators::FormattableProperty
include API::Decorators::LinkedResource
self_link title_getter: ->(*) { represented.filename }
associated_resource :author,
v3_path: :user,
representer: ::API::V3::Users::UserRepresenter
def self.associated_container_getter
->(*) do
next unless embed_links && container_representer
container_representer
.new(represented.container, current_user: current_user)
end
end
def self.associated_container_link
->(*) do
return nil unless v3_container_name == 'nil_class' || api_v3_paths.respond_to?(v3_container_name)
::API::Decorators::LinkObject
.new(represented,
path: v3_container_name,
property_name: :container,
title_attribute: container_title_attribute)
.to_hash
end
end
attr_reader :form_url
attr_reader :form_fields
attr_reader :attachment
def initialize(attachment, options = {})
super
fog_hash = DirectFogUploader.direct_fog_hash attachment: attachment
@form_url = fog_hash[:uri]
@form_fields = fog_hash.except :uri
@attachment = attachment
end
associated_resource :container,
getter: associated_container_getter,
link: associated_container_link
link :addAttachment do
{
href: form_url,
method: :post,
form_fields: form_fields
}
end
link :delete do
{
href: api_v3_paths.attachment_upload(represented.id),
method: :delete
}
end
link :staticDownloadLocation do
{
href: api_v3_paths.attachment_content(attachment.id)
}
end
link :downloadLocation do
location = if attachment.external_storage?
attachment.external_url
else
api_v3_paths.attachment_content(attachment.id)
end
{
href: location
}
end
link :completeUpload do
{
href: api_v3_paths.attachment_uploaded(attachment.id)
}
end
property :id
property :file_name,
getter: ->(*) { filename }
formattable_property :description,
plain: true
date_time_property :created_at
def _type
'AttachmentUpload'
end
def container_representer
name = v3_container_name.camelcase
"::API::V3::#{name.pluralize}::#{name}Representer".constantize
rescue NameError
nil
end
def v3_container_name
::API::Utilities::PropertyNameConverter.from_ar_name(represented.container.class.name.underscore).underscore
end
def container_title_attribute
represented.container.respond_to?(:subject) ? :subject : :title
end
end
end
end
end

@ -39,13 +39,25 @@ module API
def container def container
nil nil
end end
def check_attachments_addable
raise API::Errors::Unauthorized if Redmine::Acts::Attachable.attachables.none?(&:attachments_addable?)
end
end end
post do post do
raise API::Errors::Unauthorized if Redmine::Acts::Attachable.attachables.none?(&:attachments_addable?) check_attachments_addable
::API::V3::Attachments::AttachmentRepresenter.new(parse_and_create, current_user: current_user)
end
namespace :prepare do
post do
require_direct_uploads
check_attachments_addable
::API::V3::Attachments::AttachmentRepresenter.new(parse_and_create, ::API::V3::Attachments::AttachmentUploadRepresenter.new(parse_and_prepare, current_user: current_user)
current_user: current_user) end
end end
route_param :id, type: Integer, desc: 'Attachment ID' do route_param :id, type: Integer, desc: 'Attachment ID' do
@ -70,6 +82,18 @@ module API
respond_with_attachment @attachment, cache_seconds: 604799 respond_with_attachment @attachment, cache_seconds: 604799
end end
end end
namespace :uploaded do
get do
attachment = Attachment.pending_direct_uploads.where(id: params[:id]).first!
raise API::Errors::NotFound unless attachment.file.readable?
::Attachments::FinishDirectUploadJob.perform_later attachment.id
::API::V3::Attachments::AttachmentRepresenter.new(attachment, current_user: current_user)
end
end
end end
end end
end end

@ -56,13 +56,42 @@ module API
unless metadata.file_name unless metadata.file_name
raise ::API::Errors::Validation.new( raise ::API::Errors::Validation.new(
:file_name, :file_name,
"fileName #{I18n.t('activerecord.errors.messages.blank')}." "fileName #{I18n.t('activerecord.errors.messages.blank')}"
) )
end end
metadata metadata
end end
def parse_and_prepare
metadata = parse_metadata params[:metadata]
unless metadata
raise ::API::Errors::InvalidRequestBody.new(I18n.t('api_v3.errors.multipart_body_error'))
end
unless metadata.file_size
raise ::API::Errors::Validation.new(
:file_size,
"fileSize #{I18n.t('activerecord.errors.messages.blank')}"
)
end
with_handled_create_errors do
create_attachment metadata
end
end
def create_attachment(metadata)
Attachment.create_pending_direct_upload(
file_name: metadata.file_name,
container: container,
author: current_user,
content_type: metadata.content_type,
file_size: metadata.file_size
)
end
def parse_and_create def parse_and_create
metadata = parse_metadata params[:metadata] metadata = parse_metadata params[:metadata]
file = params[:file] file = params[:file]
@ -87,6 +116,20 @@ module API
end end
end end
def check_permissions(permissions)
if permissions.empty?
raise API::Errors::Unauthorized unless container.attachments_addable?(current_user)
else
authorize_any(permissions, projects: container.project)
end
end
def require_direct_uploads
unless OpenProject::Configuration.direct_uploads?
raise API::Errors::NotFound, message: "Only available if direct uploads are enabled."
end
end
def with_handled_create_errors def with_handled_create_errors
yield yield
rescue ActiveRecord::RecordInvalid => error rescue ActiveRecord::RecordInvalid => error
@ -125,16 +168,21 @@ module API
def self.create(permissions = []) def self.create(permissions = [])
-> do -> do
if permissions.empty? check_permissions permissions
raise API::Errors::Unauthorized unless container.attachments_addable?(current_user)
else
authorize_any(permissions, projects: container.project)
end
::API::V3::Attachments::AttachmentRepresenter.new(parse_and_create, ::API::V3::Attachments::AttachmentRepresenter.new(parse_and_create,
current_user: current_user) current_user: current_user)
end end
end end
def self.prepare(permissions = [])
-> do
require_direct_uploads
check_permissions permissions
::API::V3::Attachments::AttachmentUploadRepresenter.new(parse_and_prepare, current_user: current_user)
end
end
end end
end end
end end

@ -45,6 +45,10 @@ module API
get &API::V3::Attachments::AttachmentsByContainerAPI.read get &API::V3::Attachments::AttachmentsByContainerAPI.read
post &API::V3::Attachments::AttachmentsByContainerAPI.create([:edit_messages]) post &API::V3::Attachments::AttachmentsByContainerAPI.create([:edit_messages])
namespace :prepare do
post &API::V3::Attachments::AttachmentsByContainerAPI.prepare([:edit_messages])
end
end end
end end
end end

@ -45,6 +45,10 @@ module API
get &API::V3::Attachments::AttachmentsByContainerAPI.read get &API::V3::Attachments::AttachmentsByContainerAPI.read
post &API::V3::Attachments::AttachmentsByContainerAPI.create post &API::V3::Attachments::AttachmentsByContainerAPI.create
namespace :prepare do
post &API::V3::Attachments::AttachmentsByContainerAPI.prepare
end
end end
end end
end end

@ -54,6 +54,10 @@ module API
# :edit_work_packages in this endpoint and require clients to upload uncontainered work packages # :edit_work_packages in this endpoint and require clients to upload uncontainered work packages
# first and attach them on wp creation. # first and attach them on wp creation.
post &API::V3::Attachments::AttachmentsByContainerAPI.create([:edit_work_packages]) post &API::V3::Attachments::AttachmentsByContainerAPI.create([:edit_work_packages])
namespace :prepare do
post &API::V3::Attachments::AttachmentsByContainerAPI.prepare([:edit_work_packages])
end
end end
end end
end end

@ -46,6 +46,15 @@ module API
} }
end end
link :prepareAttachment do
next unless OpenProject::Configuration.direct_uploads?
{
href: api_v3_paths.prepare_new_attachment_upload,
method: :post
}
end
property :maximum_attachment_file_size, property :maximum_attachment_file_size,
getter: ->(*) { attachment_max_size.to_i.kilobyte } getter: ->(*) { attachment_max_size.to_i.kilobyte }

@ -123,6 +123,17 @@ module API
"#{wiki_page(id)}/attachments" "#{wiki_page(id)}/attachments"
end end
def self.prepare_new_attachment_upload
"#{root}/attachments/prepare"
end
index :attachment_upload
show :attachment_upload
def self.attachment_uploaded(attachment_id)
"#{root}/attachments/#{attachment_id}/uploaded"
end
def self.available_assignees(project_id) def self.available_assignees(project_id)
"#{project(project_id)}/available_assignees" "#{project(project_id)}/available_assignees"
end end

@ -46,6 +46,10 @@ module OpenProject
'autologin_cookie_path' => '/', 'autologin_cookie_path' => '/',
'autologin_cookie_secure' => false, 'autologin_cookie_secure' => false,
'database_cipher_key' => nil, 'database_cipher_key' => nil,
# only applicable in conjunction with fog (effectively S3) attachments
# which will be uploaded directly to the cloud storage rather than via OpenProject's
# server process.
'direct_uploads' => true,
'show_community_links' => true, 'show_community_links' => true,
'log_level' => 'info', 'log_level' => 'info',
'scm_git_command' => nil, 'scm_git_command' => nil,

@ -41,6 +41,20 @@ module OpenProject
(self['attachments_storage'] || 'file').to_sym (self['attachments_storage'] || 'file').to_sym
end end
##
# We only allow direct uploads to S3 as we are using the carrierwave_direct
# gem which only supports S3 for the time being.
def direct_uploads
return false unless remote_storage?
return false unless remote_storage_aws?
self['direct_uploads']
end
def direct_uploads?
direct_uploads
end
# Augur connect host # Augur connect host
def enterprise_trial_creation_host def enterprise_trial_creation_host
if Rails.env.production? if Rails.env.production?
@ -54,6 +68,20 @@ module OpenProject
attachments_storage == :file attachments_storage == :file
end end
def remote_storage?
attachments_storage == :fog
end
def remote_storage_aws?
fog_credentials[:provider] == "AWS"
end
def remote_storage_host
if remote_storage_aws?
"#{fog_directory}.s3.amazonaws.com"
end
end
def attachments_storage_path def attachments_storage_path
Rails.root.join(self['attachments_storage_path'] || 'files') Rails.root.join(self['attachments_storage_path'] || 'files')
end end

@ -57,6 +57,21 @@ namespace :parallel do
group_options group_options
end end
##
# Returns all spec folder paths
# of the core, modules and plugins
def all_spec_paths
spec_folders = ['spec'] + Plugins::LoadPathHelper.spec_load_paths
spec_folders.join(' ')
end
##
# Returns all spec folder paths
# of the core, modules and plugins
def plugin_spec_paths
Plugins::LoadPathHelper.spec_load_paths.join(' ')
end
def run_specs(parsed_options, folders, pattern = '', additional_options: nil) def run_specs(parsed_options, folders, pattern = '', additional_options: nil)
check_for_pending_migrations check_for_pending_migrations
@ -103,12 +118,10 @@ namespace :parallel do
desc 'Run plugin specs in parallel' desc 'Run plugin specs in parallel'
task specs: [:environment] do task specs: [:environment] do
spec_folders = Plugins::LoadPathHelper.spec_load_paths.join(' ')
ParallelParser.with_args(ARGV) do |options| ParallelParser.with_args(ARGV) do |options|
ARGV.each { |a| task(a.to_sym) {} } ARGV.each { |a| task(a.to_sym) {} }
run_specs options, spec_folders run_specs options, plugin_spec_paths
end end
end end
@ -116,12 +129,10 @@ namespace :parallel do
task units: [:environment] do task units: [:environment] do
pattern = "--pattern 'spec/(?!features\/)'" pattern = "--pattern 'spec/(?!features\/)'"
spec_folders = Plugins::LoadPathHelper.spec_load_paths.join(' ')
ParallelParser.with_args(ARGV) do |options| ParallelParser.with_args(ARGV) do |options|
ARGV.each { |a| task(a.to_sym) {} } ARGV.each { |a| task(a.to_sym) {} }
run_specs options, spec_folders, pattern run_specs options, plugin_spec_paths, pattern
end end
end end
@ -129,12 +140,10 @@ namespace :parallel do
task features: [:environment] do task features: [:environment] do
pattern = "--pattern 'spec\/features'" pattern = "--pattern 'spec\/features'"
spec_folders = Plugins::LoadPathHelper.spec_load_paths.join(' ')
ParallelParser.with_args(ARGV) do |options| ParallelParser.with_args(ARGV) do |options|
ARGV.each { |a| task(a.to_sym) {} } ARGV.each { |a| task(a.to_sym) {} }
run_specs options, spec_folders, pattern run_specs options, plugin_spec_paths, pattern
end end
end end
@ -164,29 +173,29 @@ namespace :parallel do
ParallelParser.with_args(ARGV) do |options| ParallelParser.with_args(ARGV) do |options|
ARGV.each { |a| task(a.to_sym) {} } ARGV.each { |a| task(a.to_sym) {} }
run_specs options, 'spec' run_specs options, all_spec_paths
end end
end end
desc 'Run feature specs in parallel' desc 'Run feature specs in parallel'
task features: [:environment] do task features: [:environment] do
pattern = "--pattern '^spec\/features\/'" pattern = "--pattern 'spec\/features\/'"
ParallelParser.with_args(ARGV) do |options| ParallelParser.with_args(ARGV) do |options|
ARGV.each { |a| task(a.to_sym) {} } ARGV.each { |a| task(a.to_sym) {} }
run_specs options, 'spec', pattern run_specs options, all_spec_paths, pattern
end end
end end
desc 'Run unit specs in parallel' desc 'Run unit specs in parallel'
task units: [:environment] do task units: [:environment] do
pattern = "--pattern '^spec/(?!features\/)'" pattern = "--pattern 'spec/(?!features\/)'"
ParallelParser.with_args(ARGV) do |options| ParallelParser.with_args(ARGV) do |options|
ARGV.each { |a| task(a.to_sym) {} } ARGV.each { |a| task(a.to_sym) {} }
run_specs options, 'spec', pattern run_specs options, all_spec_paths, pattern
end end
end end
end end

@ -5,11 +5,11 @@ ar:
button_update: 'التحديث' button_update: 'التحديث'
avatars: avatars:
label_choose_avatar: "Choose Avatar from file" label_choose_avatar: "Choose Avatar from file"
uploading_avatar: "Uploading your avatar." uploading_avatar: "تحميل الصورة الرمزية"
text_upload_instructions: | text_upload_instructions: |
Upload your own custom avatar of 128 by 128 pixels. Larger files will be resized and cropped to match. Upload your own custom avatar of 128 by 128 pixels. Larger files will be resized and cropped to match.
A preview of your avatar will be shown before uploading, once you selected an image. A preview of your avatar will be shown before uploading, once you selected an image.
error_image_too_large: "Image is too large." error_image_too_large: "الصورة كبيرة جداً."
wrong_file_format: "Allowed formats are jpg, png, gif" wrong_file_format: "Allowed formats are jpg, png, gif"
empty_file_error: "Please upload a valid image (jpg, png, gif)" empty_file_error: "Please upload a valid image (jpg, png, gif)"

@ -38,9 +38,6 @@ class Sprint < Version
scope :order_by_date, -> { scope :order_by_date, -> {
reorder(Arel.sql("start_date ASC NULLS LAST, effective_date ASC NULLS LAST")) reorder(Arel.sql("start_date ASC NULLS LAST, effective_date ASC NULLS LAST"))
} }
scope :order_by_name, -> {
order Arel.sql("#{Version.table_name}.name ASC")
}
scope :apply_to, lambda { |project| scope :apply_to, lambda { |project|
where("#{Version.table_name}.project_id = #{project.id}" + where("#{Version.table_name}.project_id = #{project.id}" +

@ -45,13 +45,13 @@ ar:
work_package: work_package:
attributes: attributes:
version_id: version_id:
task_version_must_be_the_same_as_story_version: "must be the same as the parent story's version." task_version_must_be_the_same_as_story_version: "يجب أن يكون نفس نسخة القصة الأصلية."
parent_id: parent_id:
parent_child_relationship_across_projects: "هو غير صالح لأن مجموعة العمل '%{work_package_name}' هي مهمة عمل متراكم غير منجز ولذلك لا يمكن أن يكون أحد الأصول خارج المشروع الحالي." parent_child_relationship_across_projects: "هو غير صالح لأن مجموعة العمل '%{work_package_name}' هي مهمة عمل متراكم غير منجز ولذلك لا يمكن أن يكون أحد الأصول خارج المشروع الحالي."
backlogs: backlogs:
add_new_story: "قصة جديدة" add_new_story: "قصة جديدة"
any: "أي" any: "أي"
backlog_settings: "Backlogs settings" backlog_settings: "إعدادات السجلات المتراكمة"
burndown_graph: "الرسم البياني لتقدم العمل" burndown_graph: "الرسم البياني لتقدم العمل"
card_paper_size: "حجم الورق لطباعة البطاقة" card_paper_size: "حجم الورق لطباعة البطاقة"
chart_options: "خيارات الرسم البياني" chart_options: "خيارات الرسم البياني"
@ -102,8 +102,8 @@ ar:
backlogs_velocity_missing: "لا يمكن احتساب السرعة في هذا المشروع" backlogs_velocity_missing: "لا يمكن احتساب السرعة في هذا المشروع"
backlogs_velocity_varies: "السرعة تتفاوت بشكل ملحوظ على السباقات" backlogs_velocity_varies: "السرعة تتفاوت بشكل ملحوظ على السباقات"
backlogs_wiki_template: "نموذج لصفحة ويكي wiki الخاصة بالسباق" backlogs_wiki_template: "نموذج لصفحة ويكي wiki الخاصة بالسباق"
backlogs_empty_title: "No versions are defined to be used in backlogs" backlogs_empty_title: "لا توجد إصدارات محددة لاستخدامها في قائمة الأعمال"
backlogs_empty_action_text: "To get started with backlogs, create a new version and assign it to a backlogs column." backlogs_empty_action_text: "للبدء مع قائمة الأعمال ، قم بإنشاء إصدار جديد و تعيينه إلى عمود قائمة الأعمال."
button_edit_wiki: "عدّل صفحة ويكي wiki" button_edit_wiki: "عدّل صفحة ويكي wiki"
error_intro_plural: "تم مصادفة الأخطاء التالية:" error_intro_plural: "تم مصادفة الأخطاء التالية:"
error_intro_singular: "تمت مصادفة الخطأ التالي:" error_intro_singular: "تمت مصادفة الخطأ التالي:"

@ -68,16 +68,20 @@ module Bim
end end
def ifc_attachment_is_ifc def ifc_attachment_is_ifc
return unless model.ifc_attachment&.new_record? return unless model.ifc_attachment&.new_record? || model.ifc_attachment&.pending_direct_upload?
firstline = File.open(model.ifc_attachment.file.file.path, &:readline) file_path = model.ifc_attachment.file.local_file.path
begin begin
firstline = File.open(file_path, &:readline)
unless firstline.match?(/^ISO-10303-21;/) unless firstline.match?(/^ISO-10303-21;/)
errors.add :base, :invalid_ifc_file errors.add :base, :invalid_ifc_file
end end
rescue ArgumentError rescue ArgumentError
errors.add :base, :invalid_ifc_file errors.add :base, :invalid_ifc_file
ensure
FileUtils.rm file_path if File.exists? file_path
end end
end end

@ -33,11 +33,12 @@ module Bim
class IfcModelsController < BaseController class IfcModelsController < BaseController
helper_method :gon helper_method :gon
before_action :find_project_by_project_id, only: %i[index new create show defaults edit update destroy] before_action :find_project_by_project_id, only: %i[index new create show defaults edit update destroy direct_upload_finished]
before_action :find_ifc_model_object, only: %i[edit update destroy] before_action :find_ifc_model_object, only: %i[edit update destroy]
before_action :find_all_ifc_models, only: %i[show defaults index] before_action :find_all_ifc_models, only: %i[show defaults index]
before_action :authorize before_action :authorize, except: [:set_direct_upload_file_name, :direct_upload_finished]
skip_before_action :verify_authenticity_token, only: [:set_direct_upload_file_name]
menu_item :ifc_models menu_item :ifc_models
@ -48,9 +49,25 @@ module Bim
def new def new
@ifc_model = @project.ifc_models.build @ifc_model = @project.ifc_models.build
if OpenProject::Configuration.direct_uploads?
@pending_upload = Attachment.create_pending_direct_upload(file_name: "model.ifc", author: current_user)
@form = DirectFogUploader.direct_fog_hash(
attachment: @pending_upload,
success_action_redirect: direct_upload_finished_bcf_project_ifc_models_url
)
end
end end
def edit; def edit
if OpenProject::Configuration.direct_uploads?
@pending_upload = Attachment.create_pending_direct_upload(file_name: "model.ifc", author: current_user)
@form = DirectFogUploader.direct_fog_hash(
attachment: @pending_upload,
success_action_redirect: direct_upload_finished_bcf_project_ifc_models_url
)
session[:pending_ifc_model_ifc_model_id] = @ifc_model.id
end
end end
def show def show
@ -61,6 +78,70 @@ module Bim
frontend_redirect @ifc_models.defaults.pluck(:id).uniq frontend_redirect @ifc_models.defaults.pluck(:id).uniq
end end
def set_direct_upload_file_name
session[:pending_ifc_model_title] = params[:title]
session[:pending_ifc_model_is_default] = params[:isDefault]
end
def direct_upload_finished
id = request.params[:key].scan(/\/file\/(\d+)\//).flatten.first
attachment = Attachment.pending_direct_uploads.where(id: id).first
if attachment.nil? # this should not happen
flash[:error] = "Direct upload failed."
redirect_to action: :new
end
params = {
title: session[:pending_ifc_model_title],
project: @project,
ifc_attachment: attachment,
is_default: session[:pending_ifc_model_is_default]
}
new_model = true
if session[:pending_ifc_model_ifc_model_id]
ifc_model = Bim::IfcModels::IfcModel.find_by id: session[:pending_ifc_model_ifc_model_id]
new_model = false
call = ::Bim::IfcModels::UpdateService
.new(user: current_user, model: ifc_model)
.call(params.with_indifferent_access)
@ifc_model = call.result
else
call = ::Bim::IfcModels::CreateService
.new(user: current_user)
.call(params.with_indifferent_access)
@ifc_model = call.result
end
session.delete :pending_ifc_model_title
session.delete :pending_ifc_model_is_default
session.delete :pending_ifc_model_ifc_model_id
if call.success?
::Attachments::FinishDirectUploadJob.perform_later attachment.id
if new_model
flash[:notice] = t('ifc_models.flash_messages.upload_successful')
else
flash[:notice] = t(:notice_successful_update)
end
redirect_to action: :index
else
attachment.destroy
flash[:error] = call.errors.full_messages.join(" ")
redirect_to action: :new
end
end
def create def create
combined_params = permitted_model_params combined_params = permitted_model_params
.to_h .to_h

@ -39,7 +39,7 @@ module Bim
super super
change_by_system do change_by_system do
model.uploader = model.ifc_attachment&.author if model.ifc_attachment&.new_record? model.uploader = model.ifc_attachment&.author if model.ifc_attachment&.new_record? || model.ifc_attachment&.pending_direct_upload?
end end
end end
@ -61,15 +61,23 @@ module Bim
end end
def set_title def set_title
model.title = model.ifc_attachment&.file&.filename&.gsub(/\.\w+$/, '') model.title ||= model.ifc_attachment&.file&.filename&.gsub(/\.\w+$/, '')
end end
def set_ifc_attachment(ifc_attachment) def set_ifc_attachment(ifc_attachment)
return unless ifc_attachment return unless ifc_attachment
model.attachments.each(&:mark_for_destruction) model.attachments.each(&:mark_for_destruction)
if ifc_attachment.is_a?(Attachment)
ifc_attachment.description = "ifc"
ifc_attachment.save! unless ifc_attachment.new_record?
model.attachments << ifc_attachment
else
model.attach_files('first' => {'file' => ifc_attachment, 'description' => 'ifc'}) model.attach_files('first' => {'file' => ifc_attachment, 'description' => 'ifc'})
end end
end end
end end
end end
end

@ -35,9 +35,59 @@ See doc/COPYRIGHT.rdoc for more details.
<%= f.text_field :title, required: true, container_class: '-wide' %> <%= f.text_field :title, required: true, container_class: '-wide' %>
</div> </div>
<% end %> <% end %>
<% if OpenProject::Configuration.direct_uploads? %>
<% Hash(@form).each do |key, value| %>
<input type="hidden" name="<%= key %>" value="<%= value %>" />
<% end %>
<% end %>
<div class="form--field <%= @ifc_model.new_record? ? '-required': '' %>"> <div class="form--field <%= @ifc_model.new_record? ? '-required': '' %>">
<% if OpenProject::Configuration.direct_uploads? %>
<input class="form--file-field" type="file" name="file" />
<% else %>
<%= f.file_field :ifc_attachment %> <%= f.file_field :ifc_attachment %>
<% end %>
</div> </div>
<div class="form--field"> <div class="form--field">
<%= f.check_box 'is_default' %> <%= f.check_box 'is_default' %>
</div> </div>
<% if OpenProject::Configuration.direct_uploads? %>
<%= nonced_javascript_tag do %>
jQuery(document).ready(function() {
jQuery("input[type=file]").change(function(e){
var fileName = e.target.files[0].name;
jQuery.post(
"<%= set_direct_upload_file_name_bcf_project_ifc_models_path %>",
{
title: fileName,
isDefault: jQuery("#bim_ifc_models_ifc_model_is_default").is(":checked") ? 1 : 0
}
);
// rebuild form to post to S3 directly
if (jQuery("input[name=utf8]").length == 1) {
jQuery("input[name=utf8]").remove();
jQuery("input[name=authenticity_token]").remove();
jQuery("input[name=_method]").remove();
var url = jQuery("input[name=uri]").val();
jQuery("form").attr("action", url);
jQuery("form").attr("enctype", "multipart/form-data");
jQuery("input[name=uri]").remove();
jQuery("form").submit(function() {
jQuery("#bim_ifc_models_ifc_model_title").prop("disabled", "disabled");
});
}
});
});
<% end %>
<% end %>

@ -4,17 +4,17 @@ ar:
label_bim: 'BIM' label_bim: 'BIM'
bcf: bcf:
label_bcf: 'BCF' label_bcf: 'BCF'
label_imported_failed: 'Failed imports of BCF topics' label_imported_failed: 'فشل استيراد مواضيع BCF'
label_imported_successfully: 'Successfully imported BCF topics' label_imported_successfully: 'تم استيراد موضوعات BCF بنجاح'
issues: "Issues" issues: "مشاكل"
recommended: 'recommended' recommended: 'موصى بها'
not_recommended: 'not recommended' not_recommended: 'غير موصى بها'
no_viewpoints: 'No viewpoints' no_viewpoints: 'لا توجد وجهات نظر'
new_badge: "جديد" new_badge: "جديد"
exceptions: exceptions:
file_invalid: "BCF file invalid" file_invalid: "ملف BCF غير صالح"
x_bcf_issues: x_bcf_issues:
zero: 'No BCF issues' zero: 'لا توجد مشاكل BCF'
zero: '%{count} BCF issues' zero: '%{count} BCF issues'
one: 'One BCF issue' one: 'One BCF issue'
two: '%{count} BCF issues' two: '%{count} BCF issues'
@ -30,37 +30,37 @@ ar:
import_failed_unsupported_bcf_version: 'Failed to read the BCF file: The BCF version is not supported. Please ensure the version is at least %{minimal_version} or higher.' import_failed_unsupported_bcf_version: 'Failed to read the BCF file: The BCF version is not supported. Please ensure the version is at least %{minimal_version} or higher.'
import_successful: 'Imported %{count} BCF issues' import_successful: 'Imported %{count} BCF issues'
import_canceled: 'BCF-XML import canceled.' import_canceled: 'BCF-XML import canceled.'
type_not_active: "The issue type is not activated for this project." type_not_active: "لم يتم تفعيل نوع المشكلة لهذا المشروع."
import: import:
num_issues_found: '%{x_bcf_issues} are contained in the BCF-XML file, their details are listed below.' num_issues_found: '%{x_bcf_issues} موجودة في ملف BCF-XML ، وترد تفاصيلها أدناه.'
button_prepare: 'Prepare import' button_prepare: 'إعداد الاستيراد'
button_perform_import: 'Confirm import' button_perform_import: 'تأكيد الاستيراد'
button_proceed: 'Proceed with import' button_proceed: 'Proceed with import'
button_back_to_list: 'Back to list' button_back_to_list: 'رجوع إلى القائمة'
no_permission_to_add_members: 'You do not have sufficient permissions to add them as members to the project.' no_permission_to_add_members: 'ليس لديك الصلاحيات الكافية لإضافتها كأعضاء في المشروع.'
contact_project_admin: 'Contact your project admin to add them as members and start this import again.' contact_project_admin: 'اتصل بمشرف المشروع الخاص بك لإضافته كأعضاء وبدء هذا الاستيراد مرة أخرى.'
continue_anyways: 'Do you want to proceed and finish the import anyways?' continue_anyways: 'هل تريد المضي قدما وإنهاء الاستيراد على أي حال؟'
description: "Provide a BCF-XML v2.1 file to import into this project. You can examine its contents before performing the import." description: "توفير ملف BCF-XML v2.1 للاستيراد إلى هذا المشروع. يمكنك فحص محتوياته قبل إجراء الاستيراد."
invalid_types_found: 'Invalid topic type names found' invalid_types_found: 'تم العثور على أسماء غير صالحة لنوع الموضوع'
invalid_statuses_found: 'Invalid status names found' invalid_statuses_found: 'تم العثور على أسماء غير صالحة لنوع الموضوع'
invalid_priorities_found: 'Invalid priority names found' invalid_priorities_found: 'تم العثور على أسماء غير صالحة لنوع الموضوع'
invalid_emails_found: 'Invalid email addresses found' invalid_emails_found: 'عنوان البريد الإلكتروني غير صالح'
unknown_emails_found: 'Unknown email addresses found' unknown_emails_found: 'عنوان البريد الإلكتروني غير صالح'
unknown_property: 'Unknown property' unknown_property: 'خاصية غير معروفة'
non_members_found: 'Non project members found' non_members_found: 'لم يتم العثور على أعضاء المشروع'
import_types_as: 'Set all these types to' import_types_as: 'تعيين جميع هذه الأنواع إلى'
import_statuses_as: 'Set all these statuses to' import_statuses_as: 'تعيين جميع هذه الحالات إلى'
import_priorities_as: 'Set all these priorities to' import_priorities_as: 'تعيين جميع هذه الأولويات إلى'
invite_as_members_with_role: 'Invite them as members to the project "%{project}" with role' invite_as_members_with_role: 'قم بدعوتهم كأعضاء في المشروع "%{project}" مع دور'
add_as_members_with_role: 'Add them as members to the project "%{project}" with role' add_as_members_with_role: 'قم بدعوتهم كأعضاء في المشروع "%{project}" مع دور'
no_type_provided: 'No type provided' no_type_provided: 'لا يوجد نوع'
no_status_provided: 'No status provided' no_status_provided: 'لا توجد حالة'
no_priority_provided: 'No priority provided' no_priority_provided: 'لا توجد أولوية'
perform_description: "Do you want to import or update the issues listed above?" perform_description: "هل تريد استيراد أو تحديث المشكلات المدرجة أعلاه؟"
replace_with_system_user: 'Replace them with "System" user' replace_with_system_user: 'استبدالها بمستخدم "النظام"'
import_as_system_user: 'Import them as "System" user.' import_as_system_user: 'استيرادها كمستخدم "النظام".'
what_to_do: "ماذا تريد أن تفعل؟" what_to_do: "ماذا تريد أن تفعل؟"
work_package_has_newer_changes: "Outdated! This topic was not updated as the latest changes on the server were newer than the \"ModifiedDate\" of the imported topic. However, comments to the topic were imported." work_package_has_newer_changes: "انتهت صلاحيتها! لم يتم تحديث هذا الموضوع لأن أحدث التغييرات على الخادم كانت أحدث من \"تاريخ التعديل\" للموضوع المستورد. غير أن التعليقات على الموضوع قد استُوردت."
bcf_file_not_found: "Failed to locate BCF file. Please start the upload process again." bcf_file_not_found: "Failed to locate BCF file. Please start the upload process again."
export: export:
format: format:
@ -69,13 +69,13 @@ ar:
bcf_thumbnail: "BCF snapshot" bcf_thumbnail: "BCF snapshot"
project_module_bcf: "BCF" project_module_bcf: "BCF"
project_module_bim: "BCF" project_module_bim: "BCF"
permission_view_linked_issues: "View BCF issues" permission_view_linked_issues: "لا توجد مشاكل BCF"
permission_manage_bcf: "Import and manage BCF issues" permission_manage_bcf: "استيراد وإدارة مشكلات BCF"
permission_delete_bcf: "Delete BCF issues" permission_delete_bcf: "Delete BCF issues"
oauth: oauth:
scopes: scopes:
bcf_v2_1: "Full access to the BCF v2.1 API" bcf_v2_1: "الوصول الكامل إلى BCF v2.1 API"
bcf_v2_1_text: "Application will receive full read & write access to the OpenProject BCF API v2.1 to perform actions on your behalf." bcf_v2_1_text: "سيحصل التطبيق على الوصول الكامل للقراءة والكتابة إلى OpenProject BCF API v2.1 لتنفيذ الإجراءات نيابة عنك."
activerecord: activerecord:
models: models:
bim/ifc_models/ifc_model: "IFC model" bim/ifc_models/ifc_model: "IFC model"

@ -101,7 +101,7 @@ es:
snapshot_data_blank: "«snapshot_data» tiene que especificarse." snapshot_data_blank: "«snapshot_data» tiene que especificarse."
unsupported_key: "Se ha incluido una propiedad JSON no admitida." unsupported_key: "Se ha incluido una propiedad JSON no admitida."
bim/bcf/issue: bim/bcf/issue:
uuid_already_taken: "Can't import this BCF issue as there already is another with the same GUID. Could it be that this BCF issue had already been imported into a different project?" uuid_already_taken: "No se puede importar el defecto de BCF porque ya existe otro con el mismo GUID ¿Podría ser \nque este defecto BCF ya fue importado a un proyecto diferente?"
ifc_models: ifc_models:
label_ifc_models: 'Modelos IFC' label_ifc_models: 'Modelos IFC'
label_new_ifc_model: 'Nuevo modelo IFC' label_new_ifc_model: 'Nuevo modelo IFC'

@ -18,8 +18,8 @@ es:
manage: 'Administrar modelos' manage: 'Administrar modelos'
views: views:
viewer: 'Visor' viewer: 'Visor'
split: 'Viewer and table' split: 'Visor y tabla'
split_cards: 'Visor y tarjetas' split_cards: 'Visor y tarjetas'
revit: revit:
revit_add_in: "Revit Add-In" revit_add_in: "Revisar agregado"
revit_add_in_settings: "Revit Add-In settings" revit_add_in_settings: "Revisar ajustes de complemento"

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save