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
- stage: test
name: 'spec_legacy (1/1)'
name: 'legacy specs + cukes (1/1)'
script:
- bash script/ci/setup.sh spec_legacy
- bash script/ci/runner.sh spec_legacy 1 1
- bash script/ci/runner.sh plugins:cucumber 1 1
- stage: test
name: 'units (1/4)'
name: 'units (1/5)'
script:
- bash script/ci/setup.sh units
- bash script/ci/runner.sh units 4 1
- bash script/ci/runner.sh units 5 1
- stage: test
name: 'units (2/4)'
name: 'units (2/5)'
script:
- bash script/ci/setup.sh units
- bash script/ci/runner.sh units 4 2
- bash script/ci/runner.sh units 5 2
- stage: test
name: 'units (3/4)'
name: 'units (3/5)'
script:
- bash script/ci/setup.sh units
- bash script/ci/runner.sh units 4 3
- bash script/ci/runner.sh units 5 3
- stage: test
name: 'units (4/4)'
name: 'units (4/5)'
script:
- bash script/ci/setup.sh units
- bash script/ci/runner.sh units 4 4
- bash script/ci/runner.sh units 5 4
- stage: test
name: 'features (1/4)'
name: 'units (5/5)'
script:
- bash script/ci/setup.sh features
- bash script/ci/runner.sh features 4 1
- bash script/ci/setup.sh units
- bash script/ci/runner.sh units 5 5
- stage: test
name: 'features (2/4)'
name: 'features (1/6)'
script:
- bash script/ci/setup.sh features
- bash script/ci/runner.sh features 4 2
- bash script/ci/runner.sh features 6 1
- stage: test
name: 'features (3/4)'
name: 'features (2/6)'
script:
- bash script/ci/setup.sh features
- bash script/ci/runner.sh features 4 3
- bash script/ci/runner.sh features 6 2
- stage: test
name: 'features (4/4)'
name: 'features (3/6)'
script:
- bash script/ci/setup.sh features
- bash script/ci/runner.sh features 4 4
- bash script/ci/runner.sh features 6 3
- stage: test
name: 'plugins:units (1/1)'
name: 'features (4/6)'
script:
- bash script/ci/setup.sh plugins:units
- bash script/ci/runner.sh plugins:units 1 1
if: head_branch !~ /^core\//
- bash script/ci/setup.sh features
- bash script/ci/runner.sh features 6 4
- stage: test
name: 'plugins:features (1/1)'
name: 'features (5/6)'
script:
- bash script/ci/setup.sh plugins:features
- bash script/ci/runner.sh plugins:features 1 1
if: head_branch !~ /^core\//
- bash script/ci/setup.sh features
- bash script/ci/runner.sh features 6 5
- stage: test
name: 'plugins:cucumber (1/1)'
name: 'features (6/6)'
script:
- bash script/ci/setup.sh plugins:cucumber
- bash script/ci/runner.sh plugins:cucumber 1 1
if: head_branch !~ /^core\//
- bash script/ci/setup.sh features
- bash script/ci/runner.sh features 6 6
addons:
chrome: stable

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

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

@ -251,6 +251,34 @@ class Attachment < ApplicationRecord
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
def schedule_cleanup_uncontainered_job

@ -32,4 +32,12 @@ class Queries::WorkPackages::Filter::EstimatedHoursFilter <
def type
:integer
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

@ -58,7 +58,7 @@ class Version < ApplicationRecord
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
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(too_old)
.destroy_all
Attachment
.pending_direct_uploads
.where(too_old)
.destroy_all # prepared direct uploads that never finished
end
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_directory = directory
config.fog_public = public
config.use_action_status = true
end
end
end

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

@ -28,38 +28,38 @@ ar:
plugins:
no_results_title_text: لا يوجد حالياً أية إضافات متاحة.
custom_styles:
color_theme: "Color theme"
color_theme_custom: "(Custom)"
color_theme: "لون السمة"
color_theme_custom: "(تخصيص)"
colors:
alternative-color: "Alternative"
content-link-color: "Link font"
primary-color: "Primary"
primary-color-dark: "Primary (dark)"
header-bg-color: "Header background"
header-item-bg-hover-color: "Header background on hover"
header-item-font-color: "Header font"
header-item-font-hover-color: "Header font on hover"
header-border-bottom-color: "Header border"
main-menu-bg-color: "Main menu background"
main-menu-bg-selected-background: "Main menu when selected"
main-menu-bg-hover-background: "Main menu on hover"
main-menu-font-color: "Main menu font"
main-menu-selected-font-color: "Main menu font when selected"
main-menu-hover-font-color: "Main menu font on hover"
main-menu-border-color: "Main menu border"
alternative-color: "البديل"
content-link-color: "خط الارتبط"
primary-color: "الأساسي"
primary-color-dark: "الأساسي (داكن)"
header-bg-color: "خلفية الترويسة"
header-item-bg-hover-color: "خلفية الترويسة على الحافة"
header-item-font-color: "خط الترويسة"
header-item-font-hover-color: "خط الترويسة عند الحافة"
header-border-bottom-color: "حدود الترويسة"
main-menu-bg-color: "خلفية القائمة الرئيسية"
main-menu-bg-selected-background: "القائمة الرئيسية عند تحديد"
main-menu-bg-hover-background: "القائمة الرئيسية على الحافة"
main-menu-font-color: "خط القائمة الرئيسية"
main-menu-selected-font-color: "خط القائمة الرئيسية عند تحديد"
main-menu-hover-font-color: "خط القائمة الرئيسية عند الحوالة"
main-menu-border-color: "حدود القائمة الرئيسية"
custom_colors: "تخصيص الألوان"
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."
manage_colors: "Edit color select options"
enterprise_notice: "كخاصية \"شكرًا!\" على مساهمتهم المالية لتطوير OpenProject، هذه الميزة الصغيرة متاحة فقط للمشتركين في إصدار المؤسسة."
manage_colors: "تعديل خيارات تحديد اللون"
instructions:
alternative-color: "Strong accent color, typically used for the most important button on a screen."
content-link-color: "Font color of most of the links."
primary-color: "Main color."
primary-color-dark: "Typically a darker version of the main color used for hover effects."
header-item-bg-hover-color: "Background color of clickable header items when hovered with the mouse."
header-item-font-color: "Font color of clickable header items."
header-item-font-hover-color: "Font color of clickable header items when hovered with the mouse."
header-border-bottom-color: "Thin line under the header. Leave this field empty if you don't want any line."
alternative-color: "لون اللكنة القوية، يستخدم عادة لأهم زر على الشاشة."
content-link-color: "لون الخط لمعظم الروابط."
primary-color: "اللون الرئيسي."
primary-color-dark: "عادةً ما تكون نسخة داكنة من اللون الرئيسي المستخدم لتأثيرات الحرارة."
header-item-bg-hover-color: "لون الخلفية لعناصر الترويسة القابلة للنقر عند ربطها بالفأرة."
header-item-font-color: "لون الخط لعناصر الترويسة النقر عليها."
header-item-font-hover-color: "لون الخط لعناصر الترويسة القابلة للنقر عند ربطها بالفأرة."
header-border-bottom-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?
enterprise:
@ -756,6 +756,9 @@ ar:
date: "التاريخ"
default_columns: "الأعمدة الافتراضية"
description: "الوصف"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "عرض المبالغ"
due_date: "Finish date"
estimated_hours: "الوقت المُقّدَّر"

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

@ -740,6 +740,9 @@ ca:
date: "Data"
default_columns: "Columnes predeterminades"
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"
due_date: "Finish date"
estimated_hours: "Temps estimat"

@ -748,6 +748,9 @@ cs:
date: "Datum"
default_columns: "Výchozí sloupce"
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"
due_date: "Datum dokončení"
estimated_hours: "Odhadovaný čas"

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

@ -115,7 +115,7 @@ de:
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.
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.
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.
@ -735,6 +735,9 @@ de:
date: "Datum"
default_columns: "Standard-Spalten"
description: "Beschreibung"
derived_due_date: "Derived finish date"
derived_estimated_time: "Derived estimated time"
derived_start_date: "Derived start date"
display_sums: "Summen anzeigen"
due_date: "Endtermin"
estimated_hours: "Geschätzter Aufwand"

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

@ -78,7 +78,7 @@ es:
is_active: mostrado actualmente
is_inactive: no mostrado actualmente
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.'
label_plural: 'Textos de ayuda para atributos'
show_preview: 'Vista previa del texto'
@ -90,15 +90,15 @@ es:
no_results_content_text: Crear un nuevo modo de autenticación
background_jobs:
status:
error_requeue: "Job experienced an error but is retrying. The error was: %{message}"
cancelled_due_to: "Job was cancelled due to error: %{message}"
error_requeue: "El trabajo experimentó un error pero se está reintentando. El error fue: %{message}"
cancelled_due_to: "El trabajo ha sido cancelado debido al error: %{message}"
ldap_auth_sources:
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.
attribute_texts:
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`.
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."
@ -280,7 +280,7 @@ es:
overview:
no_results_title_text: Actualmente no hay paquetes de trabajo asignados a esta versión.
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.
index:
no_results_content_text: Añadir una nueva página wiki
@ -417,7 +417,7 @@ es:
types: "Tipos"
versions: "Versiones"
work_packages: "Paquetes de trabajo"
templated: 'Template project'
templated: 'Plantilla del proyecto'
projects/status:
code: 'Estado'
explanation: 'Descripción del estado'
@ -489,7 +489,7 @@ es:
parent_work_package: "Padre"
priority: "Prioridad"
progress: "Progreso (%)"
schedule_manually: "Manual scheduling"
schedule_manually: "Programación manual"
spent_hours: "Tiempo empleado"
spent_time: "Tiempo empleado"
subproject: "Subproyecto"
@ -737,6 +737,9 @@ es:
date: "Fecha"
default_columns: "Columnas predeterminadas"
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"
due_date: "Fecha de finalización"
estimated_hours: "Tiempo estimado"
@ -887,7 +890,7 @@ es:
- "Oct"
- "Nov"
- "Dec"
abbr_week: 'Wk'
abbr_week: 'Sem'
day_names:
- "Domingo"
- "Lunes"
@ -1123,8 +1126,8 @@ es:
work_package_edit: 'Paquete de trabajo editado'
work_package_note: 'Nota de paquete de trabajo añadido'
export:
your_work_packages_export: "Your work packages export"
succeeded: "The export has completed successfully."
your_work_packages_export: "Exportar paquetes de trabajo"
succeeded: "La exportación se ha completado correctamente."
format:
atom: "Atomo"
csv: "CSV"
@ -1762,16 +1765,16 @@ es:
mail_body_account_information: "Información de su cuenta"
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_register: "Welcome to OpenProject. Please activate your account by clicking on this link:"
mail_body_register_header_title: "Project member invitation email"
mail_body_register_user: "Dear %{name}, "
mail_body_register: "Bienvenido a OpenProject. Por favor, active su cuenta haciendo clic en este enlace:"
mail_body_register_header_title: "Correo electrónico de invitación al miembro del proyecto"
mail_body_register_user: "Estimado/a %{name},"
mail_body_register_links_html: |
Please feel free to browse our youtube channel (%{youtube_link}) where we provide a webinar (%{webinar_link})
and “Get started” videos (%{get_started_link}) to make your first steps in OpenProject as easy as possible.
Por favor, no dude en navegar por nuestro canal de youtube (%{youtube_link}) donde proporcionamos un webinar (%{webinar_link})
y videos "Get started" (%{get_started_link}) para hacer que sus primeros pasos en OpenProject sean lo más fáciles posible.
<br />
If you have any further questions, consult our documentation (%{documentation_link}) or contact us (%{contact_us_link}).
mail_body_register_closing: "Your OpenProject team"
mail_body_register_ending: "Stay connected! Kind regards,"
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: "Tu equipo de OpenProject"
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_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}."
@ -1927,7 +1930,7 @@ es:
permission_manage_project_activities: "Gestionar actividades del proyecto"
permission_manage_public_queries: "Administrar vistas públicas"
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_wiki: "Administrar wiki"
permission_manage_wiki_menu: "Administrar menú wiki"
@ -1964,10 +1967,10 @@ es:
title: Cambiar el identificador de proyecto
template:
copying: >
Your project is being created from the selected template project. You will be notified by mail as soon as the project is available.
use_template: 'Use template'
make_template: 'Set as template'
remove_from_templates: 'Remove from templates'
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: 'Usar plantilla'
make_template: 'Establecer como plantilla'
remove_from_templates: 'Eliminar de plantillas'
archive:
are_you_sure: "¿Está seguro que desea archivar el proyecto '%{name}'?"
archived: "Archivado"
@ -1989,8 +1992,8 @@ es:
assigned_to_role: "Asignación de roles"
member_of_group: "Asignación de grupo"
assignee_or_group: "Grupo al que pertenece o al que está asignado"
subproject_id: "Including Subproject"
only_subproject_id: "Only subproject"
subproject_id: "Incluyendo Subproyecto"
only_subproject_id: "Sólo subproyecto"
name_or_identifier: "Nombre o identificador"
repositories:
at_identifier: 'en %{identifier}'
@ -2096,8 +2099,8 @@ es:
warnings:
cannot_annotate: "No se pueden realizar notas sobre este fichero."
scheduling:
activated: 'activated'
deactivated: 'deactivated'
activated: 'Habilitado'
deactivated: 'deshabilitado'
search_input_placeholder: "Buscar..."
setting_email_delivery_method: "Método de envío de correo electrónico"
setting_sendmail_location: "Ubicación del ejecutable de sendmail"

@ -740,6 +740,9 @@ fi:
date: "Päivämäärä"
default_columns: "Oletussarakkeet"
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"
due_date: "Päättymispäivä"
estimated_hours: "Työmääräarvio"

@ -740,6 +740,9 @@ fil:
date: "Petsa"
default_columns: "I-default ang mga hanay"
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"
due_date: "Finish date"
estimated_hours: "Tinantyang oras"

@ -739,6 +739,9 @@ fr:
date: "date"
default_columns: "Colonnes par défaut"
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"
due_date: "Date de fin"
estimated_hours: "Durée estimée"

@ -744,6 +744,9 @@ hr:
date: "Datum"
default_columns: "Zadani stupci"
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"
due_date: "Finish date"
estimated_hours: "Predviđeno vrijeme"

@ -737,6 +737,9 @@ hu:
date: "dátum"
default_columns: "Alapértelmezett oszlopok"
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"
due_date: "Befejezési dátum"
estimated_hours: "Becsült idő (óra)"

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

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

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

@ -168,7 +168,7 @@ es:
trial:
confirmation: "Confirmación de dirección de correo electrónico"
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:
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>.
@ -398,7 +398,7 @@ es:
label_sum_for: "Suma para"
label_subject: "Asunto"
label_this_week: "esta semana"
label_today: "Today"
label_today: "Hoy"
label_time_entry_plural: "Tiempo empleado"
label_up: "Arriba"
label_user_plural: "Usuarios"
@ -585,8 +585,8 @@ es:
field_value_enter_prompt: "Introduzca un valor para '%{field}'"
project_menu_details: "Detalles"
scheduling:
manual: 'Manual scheduling'
automatic: 'Automatic scheduling'
manual: 'Programación manual'
automatic: 'Programación automática'
sort:
sorted_asc: 'Orden ascendiente 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?"
text_no_results: "No se encontraron vistas que coincidan."
scheduling:
is_parent: "The dates of this work package are automatically deduced from its children. Activate 'Manual scheduling' to set the dates."
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_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: "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:
configure_button: 'Configurar tabla de paquetes de trabajo'
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."
deletes_children: "También se eliminarán de forma recursiva todos los paquetes de trabajo secundarios y sus descendientes."
destroy_time_entry:
title: "Confirm deletion of time entry"
text: "Are you sure you want to delete the following time entry?"
title: "Confirmar la eliminación de la entrada de tiempo"
text: "¿Realmente quiere eliminar la siguiente entrada de tiempo?"
notice_no_results_to_display: "No se pueden mostrar resultados visibles."
notice_successful_create: "Creación exitosa."
notice_successful_delete: "Eliminado con éxito."

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

@ -743,6 +743,9 @@ lt:
date: "Data"
default_columns: "Numatytieji stulpeliai"
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"
due_date: "Pabaigos data"
estimated_hours: "Numatyta trukmė"

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

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

@ -744,6 +744,9 @@ pl:
date: "Data"
default_columns: "Domyślne kolumny"
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"
due_date: "Data zakończenia"
estimated_hours: "Szacowany czas"

@ -738,6 +738,9 @@ pt:
date: "Data"
default_columns: "Colunas padrã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"
due_date: "Data de conclusão"
estimated_hours: "Tempo estimado"

@ -744,6 +744,9 @@ ro:
date: "Dată"
default_columns: "Coloane implicite"
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"
due_date: "Finish date"
estimated_hours: "Durata estimată"

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

@ -748,6 +748,9 @@ sk:
date: "Dátum"
default_columns: "Predvolené stĺpce"
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"
due_date: "Dátum dokončenia"
estimated_hours: "Predpokladaný čas"

@ -746,6 +746,9 @@ sl:
date: "Datum"
default_columns: "Privzeti stolpci"
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"
due_date: "Končni datum"
estimated_hours: "Predvideni čas"

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

@ -740,6 +740,9 @@ tr:
date: "Tarih"
default_columns: "Varsayılan sütunlar"
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"
due_date: "Bitiş tarihi"
estimated_hours: "Tahmini süre"

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

@ -738,6 +738,9 @@ vi:
date: "Ngày"
default_columns: "Cột mặc định"
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"
due_date: "Finish date"
estimated_hours: "Thời gian dự kiến"

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

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

@ -447,6 +447,7 @@ en:
label_value_derived_from_children: "(value derived from children)"
label_warning: "Warning"
label_work_package: "Work package"
label_work_package_parent: "Parent work package"
label_work_package_plural: "Work packages"
label_watch: "Watch"
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`.
##### 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
**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)
* [`disable_password_login`](#disable-password-login) (default: false)
* [`attachments_storage`](#attachments-storage) (default: file)
* [`direct_uploads`](#direct-uploads) (default: true)
* [`hidden_menu_items`](#hidden-menu-items) (default: {})
* [`disabled_modules`](#disabled-modules) (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
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
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 {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 {OpenProjectDirectFileUploadService} from './components/api/op-file-upload/op-direct-file-upload.service';
import {LinkedPluginsModule} from "core-app/modules/plugins/linked-plugins.module";
import {HookService} from "core-app/modules/plugins/hook-service";
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 },
PaginationService,
OpenProjectFileUploadService,
OpenProjectDirectFileUploadService,
// Split view
CommentService,
ConfirmDialogService,

@ -27,10 +27,12 @@
//++
export type FilterOperator = '='|'!*'|'!'|'~'|'o'|'>t-'|'<>d'|'**'|'ow' ;
export const FalseValue = ['f'];
export const TrueValue = ['t'];
export interface ApiV3FilterValue {
operator:FilterOperator;
values:any;
values:unknown[];
}
export interface ApiV3Filter {
@ -43,7 +45,15 @@ export class ApiV3FilterBuilder {
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] = {
operator: operator,
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 {OpenProjectDirectFileUploadService} from "core-components/api/op-file-upload/op-direct-file-upload.service";
import {HttpClientTestingModule, HttpTestingController} from "@angular/common/http/testing";
import {getTestBed, TestBed} from "@angular/core/testing";
import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service";
@ -45,6 +46,7 @@ describe('opFileUpload service', () => {
{provide: States, useValue: new States()},
I18nService,
OpenProjectFileUploadService,
OpenProjectDirectFileUploadService,
HalResourceService
]
});

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

@ -3,7 +3,7 @@ import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {
catchError,
debounceTime,
distinctUntilChanged,
distinctUntilChanged, filter, share, shareReplay,
switchMap,
takeUntil,
tap
@ -37,16 +37,19 @@ export class DebouncedRequestSwitchmap<T, R = HalResource> {
/**
* @param handler switch map handler function to output a response observable
* @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
*/
constructor(readonly requestHandler:RequestSwitchmapHandler<T, R[]>,
readonly errorHandler:RequestErrorHandler,
readonly preFilterNull:boolean = false,
readonly debounceMs = 250) {
/** Output switchmap observable */
this.output$ = concat(
of([]),
this.input$.pipe(
filter(val => !preFilterNull || (val !== undefined && val !== null)),
distinctUntilChanged(),
debounceTime(debounceMs),
tap((val:T) => {
@ -66,7 +69,8 @@ export class DebouncedRequestSwitchmap<T, R = HalResource> {
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 {HalResourceService} from 'core-app/modules/hal/services/hal-resource.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 {States} from 'core-components/states.service';
import {HalResourceNotificationService} from "core-app/modules/hal/services/hal-resource-notification.service";
@ -70,7 +71,8 @@ describe('WorkPackageCache', () => {
{provide: NotificationsService, useValue: {}},
{provide: HalResourceNotificationService, useValue: {handleRawError: () => false}},
{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 {AngularTrackingHelpers} from "core-components/angular/tracking-functions";
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({
templateUrl: './add-list-modal.html'
})
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 confirmed = false;
@ -57,9 +68,6 @@ export class AddListModalComponent extends OpModalComponent implements OnInit {
/** Action service used by the board */
public actionService:BoardActionService;
/** Remaining available values */
public availableValues:HalResource[] = [];
/** The selected attribute */
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 */
public closeOnOutsideClick = false;
public valuesAvailable:boolean = true;
public warningText:string|undefined;
public text:any = {
@ -92,14 +98,22 @@ export class AddListModalComponent extends OpModalComponent implements OnInit {
public referenceOutputs = {
onCreate: (value:HalResource) => this.onNewActionCreated(value),
onOpen: () => this.requests.input$.next(''),
onChange: (value:HalResource) => this.onModelChange(value),
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,
@Inject(OpModalLocalsToken) public locals:OpModalLocalsMap,
readonly cdRef:ChangeDetectorRef,
readonly boardActions:BoardActionsRegistryService,
readonly halNotification:HalResourceNotificationService,
readonly state:StateService,
readonly boardService:BoardService,
readonly I18n:I18nService) {
@ -114,19 +128,26 @@ export class AddListModalComponent extends OpModalComponent implements OnInit {
this.active = new Set(this.locals.active as string[]);
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
.warningTextWhenNoOptionsAvailable()
.then((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) {
@ -147,7 +168,6 @@ export class AddListModalComponent extends OpModalComponent implements OnInit {
}
onNewActionCreated(newValue:HalResource) {
this.actionService.cache.clear("New attribute added.");
this.selectedAttribute = newValue;
this.create();
}

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

@ -1,14 +1,13 @@
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 {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 {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()
export class BoardAssigneeActionService extends BoardActionService {
export class BoardAssigneeActionService extends CachedBoardActionService {
filterName = 'assignee';
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
.apiV3Service
.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 {CurrentProjectService} from "core-components/projects/current-project.service";
import {Injectable, Injector} from "@angular/core";
import {take} from "rxjs/operators";
import {input} from "reactivestates";
import {map} from "rxjs/operators";
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
import {IFieldSchema} from "core-app/modules/fields/field.base";
import {WorkPackageChangeset} from "core-components/wp-edit/work-package-changeset";
import {WorkPackageFilterValues} from "core-components/wp-edit-form/work-package-filter-values";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {SchemaCacheService} from "core-components/schemas/schema-cache.service";
import {Observable} from "rxjs";
@Injectable()
export abstract class BoardActionService {
// Cache the available values for the duration of the board
readonly cache = input<HalResource[]>();
constructor(readonly injector:Injector,
protected boardListsService:BoardListsService,
protected I18n:I18nService,
@ -65,33 +62,36 @@ export abstract class BoardActionService {
/**
* Returns the current filter value ID if any
* @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);
if (filter) {
if (!filter) {
return;
}
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
* @param query
* @returns /api/v3/status/:id if a status filter exists
* @returns The loaded action reosurce
*/
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 this
.withLoadedAvailable()
.then(collection => collection.find(resource => resource.href === href));
return this.require(id);
}
/**
@ -125,13 +125,14 @@ export abstract class BoardActionService {
* Get available values from the active queries
*
* @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
.withLoadedAvailable()
.then(results =>
results.filter(item => !active.has(item.id!))
.loadValues(matching)
.pipe(
map(items => items.filter(item => !active.has(item.id!)))
);
}
@ -218,17 +219,20 @@ export abstract class BoardActionService {
filter.applyDefaultsFromFilters();
}
protected withLoadedAvailable():Promise<HalResource[]> {
this.cache.putFromPromiseIfPristine(() => this.loadAvailable());
return this.cache
.values$()
.pipe(take(1))
.toPromise();
}
/**
* Require the given resource to be loaded.
*
* @param id
* @protected
*/
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 {StatusResource} from "core-app/modules/hal/resources/status-resource";
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()
export class BoardStatusActionService extends BoardActionService {
export class BoardStatusActionService extends CachedBoardActionService {
filterName = 'status';
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> {
return this.withLoadedAvailable()
return this
.loadValues()
.toPromise()
.then((results) =>
Promise.all<unknown>(
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'));
}
protected loadAvailable():Promise<StatusResource[]> {
protected loadUncached():Promise<StatusResource[]> {
return this
.apiV3Service
.statuses
@ -48,5 +51,4 @@ export class BoardStatusActionService extends BoardActionService {
.toPromise()
.then(collection => collection.elements);
}
}

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

@ -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 {FormResource} from "core-app/modules/hal/resources/form-resource";
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()
export class BoardVersionActionService extends BoardActionService {
export class BoardVersionActionService extends CachedBoardActionService {
@InjectField() state:StateService;
@InjectField() halNotification:HalResourceNotificationService;
@ -50,7 +51,9 @@ export class BoardVersionActionService extends BoardActionService {
}
public addInitialColumnsForAction(board:Board):Promise<Board> {
return this.withLoadedAvailable()
return this
.loadValues()
.toPromise()
.then((results) => {
return Promise.all<unknown>(
results.map((version:VersionResource) => {
@ -105,7 +108,7 @@ export class BoardVersionActionService extends BoardActionService {
return value instanceof VersionResource && value.isOpen();
}
protected loadAvailable():Promise<VersionResource[]> {
protected loadUncached():Promise<HalResource[]> {
if (this.currentProject.id === null) {
return Promise.resolve([]);
}

@ -334,7 +334,7 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
}
let actionService = this.actionService!;
const id = actionService.getActionValueHrefForColumn(query);
const id = actionService.getActionValueId(query);
// Test if we loaded the resource already
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]);
if (filter) {
return filter[filterName].values[0];
return filter[filterName].values[0] as any;
}
})
.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 {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 {BoardSubtasksActionService} from "core-app/modules/boards/board/board-actions/subtasks/board-subtasks-action.service";
@Component({
selector: 'boards-entry',
@ -16,6 +17,7 @@ import {BoardSubprojectActionService} from "core-app/modules/boards/board/board-
BoardVersionActionService,
BoardAssigneeActionService,
BoardSubprojectActionService,
BoardSubtasksActionService,
QueryUpdatedService,
]
})
@ -25,14 +27,11 @@ export class BoardsRootComponent {
// Register action services
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('assignee', assigneeAction);
registry.add('version', versionAction);
registry.add('subproject', subprojectAction);
registry.add('status', injector.get(BoardStatusActionService));
registry.add('assignee', injector.get(BoardAssigneeActionService));
registry.add('version', injector.get(BoardVersionActionService));
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 { TileViewComponent } from './tile-view/tile-view.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({
imports: [
@ -88,8 +89,9 @@ import {SubprojectBoardHeaderComponent} from "core-app/modules/boards/board/boar
BoardFilterComponent,
VersionBoardHeaderComponent,
AssigneeBoardHeaderComponent,
SubprojectBoardHeaderComponent,
SubtasksBoardHeaderComponent,
TileViewComponent,
SubprojectBoardHeaderComponent
]
})
export class OpenprojectBoardsModule {

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

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

@ -26,7 +26,7 @@
// 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 {HttpErrorResponse, HttpEventType, HttpProgressEvent} from "@angular/common/http";
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: `
<li>
<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">
<op-icon icon-classes="icon-close" *ngIf="error"></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 onSuccess = new EventEmitter<undefined>();
@ViewChild('progressBar')
progressBar:ElementRef;
@ViewChild('progressPercentage')
progressPercentage:ElementRef;
public file:UploadFile;
public value:number = 0;
public error:boolean = 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) {
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 {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.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 {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;
@ -44,8 +46,10 @@ export function Attachable<TBase extends Constructor<HalResource>>(Base:TBase) {
private NotificationsService:NotificationsService;
private halNotification:HalResourceNotificationService;
private opFileUpload:OpenProjectFileUploadService;
private opDirectFileUpload:OpenProjectDirectFileUploadService;
private pathHelper:PathHelperService;
private apiV3Service:APIV3Service;
private config:ConfigurationService;
/**
* 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[]) {
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;
} else {
href = this.addAttachment.$link.href;
@ -179,6 +185,18 @@ export function Attachable<TBase extends Constructor<HalResource>>(Base:TBase) {
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() {
if (this.state) {
this.state.putValue(this as any);
@ -195,7 +213,12 @@ export function Attachable<TBase extends Constructor<HalResource>>(Base:TBase) {
if (!this.opFileUpload) {
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) {
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 {StateService} from "@uirouter/core";
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 {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";
@ -76,6 +77,7 @@ describe('WorkPackage', () => {
NotificationsService,
ConfigurationService,
OpenProjectFileUploadService,
OpenProjectDirectFileUploadService,
LoadingIndicatorService,
PathHelperService,
I18nService,

@ -178,18 +178,6 @@ export class WorkPackageBaseResource extends HalResource {
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) {
return otherWorkPackage.parent?.$links.self.$link.href === this.$links.self.$link.href;
}

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

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

@ -46,6 +46,10 @@ module API
},
setter: ->(fragment:, **) { self.description = fragment['raw'] },
render_nil: true
property :content_type, render_nil: false
property :file_size, render_nil: false
property :digest, render_nil: false
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
nil
end
def check_attachments_addable
raise API::Errors::Unauthorized if Redmine::Acts::Attachable.attachables.none?(&:attachments_addable?)
end
end
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,
current_user: current_user)
::API::V3::Attachments::AttachmentUploadRepresenter.new(parse_and_prepare, current_user: current_user)
end
end
route_param :id, type: Integer, desc: 'Attachment ID' do
@ -70,6 +82,18 @@ module API
respond_with_attachment @attachment, cache_seconds: 604799
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

@ -56,13 +56,42 @@ module API
unless metadata.file_name
raise ::API::Errors::Validation.new(
:file_name,
"fileName #{I18n.t('activerecord.errors.messages.blank')}."
"fileName #{I18n.t('activerecord.errors.messages.blank')}"
)
end
metadata
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
metadata = parse_metadata params[:metadata]
file = params[:file]
@ -87,6 +116,20 @@ module API
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
yield
rescue ActiveRecord::RecordInvalid => error
@ -125,16 +168,21 @@ module API
def self.create(permissions = [])
-> do
if permissions.empty?
raise API::Errors::Unauthorized unless container.attachments_addable?(current_user)
else
authorize_any(permissions, projects: container.project)
end
check_permissions permissions
::API::V3::Attachments::AttachmentRepresenter.new(parse_and_create,
current_user: current_user)
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

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

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

@ -54,6 +54,10 @@ module API
# :edit_work_packages in this endpoint and require clients to upload uncontainered work packages
# first and attach them on wp creation.
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

@ -46,6 +46,15 @@ module API
}
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,
getter: ->(*) { attachment_max_size.to_i.kilobyte }

@ -123,6 +123,17 @@ module API
"#{wiki_page(id)}/attachments"
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)
"#{project(project_id)}/available_assignees"
end

@ -46,6 +46,10 @@ module OpenProject
'autologin_cookie_path' => '/',
'autologin_cookie_secure' => false,
'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,
'log_level' => 'info',
'scm_git_command' => nil,

@ -41,6 +41,20 @@ module OpenProject
(self['attachments_storage'] || 'file').to_sym
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
def enterprise_trial_creation_host
if Rails.env.production?
@ -54,6 +68,20 @@ module OpenProject
attachments_storage == :file
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
Rails.root.join(self['attachments_storage_path'] || 'files')
end

@ -57,6 +57,21 @@ namespace :parallel do
group_options
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)
check_for_pending_migrations
@ -103,12 +118,10 @@ namespace :parallel do
desc 'Run plugin specs in parallel'
task specs: [:environment] do
spec_folders = Plugins::LoadPathHelper.spec_load_paths.join(' ')
ParallelParser.with_args(ARGV) do |options|
ARGV.each { |a| task(a.to_sym) {} }
run_specs options, spec_folders
run_specs options, plugin_spec_paths
end
end
@ -116,12 +129,10 @@ namespace :parallel do
task units: [:environment] do
pattern = "--pattern 'spec/(?!features\/)'"
spec_folders = Plugins::LoadPathHelper.spec_load_paths.join(' ')
ParallelParser.with_args(ARGV) do |options|
ARGV.each { |a| task(a.to_sym) {} }
run_specs options, spec_folders, pattern
run_specs options, plugin_spec_paths, pattern
end
end
@ -129,12 +140,10 @@ namespace :parallel do
task features: [:environment] do
pattern = "--pattern 'spec\/features'"
spec_folders = Plugins::LoadPathHelper.spec_load_paths.join(' ')
ParallelParser.with_args(ARGV) do |options|
ARGV.each { |a| task(a.to_sym) {} }
run_specs options, spec_folders, pattern
run_specs options, plugin_spec_paths, pattern
end
end
@ -164,29 +173,29 @@ namespace :parallel do
ParallelParser.with_args(ARGV) do |options|
ARGV.each { |a| task(a.to_sym) {} }
run_specs options, 'spec'
run_specs options, all_spec_paths
end
end
desc 'Run feature specs in parallel'
task features: [:environment] do
pattern = "--pattern '^spec\/features\/'"
pattern = "--pattern 'spec\/features\/'"
ParallelParser.with_args(ARGV) do |options|
ARGV.each { |a| task(a.to_sym) {} }
run_specs options, 'spec', pattern
run_specs options, all_spec_paths, pattern
end
end
desc 'Run unit specs in parallel'
task units: [:environment] do
pattern = "--pattern '^spec/(?!features\/)'"
pattern = "--pattern 'spec/(?!features\/)'"
ParallelParser.with_args(ARGV) do |options|
ARGV.each { |a| task(a.to_sym) {} }
run_specs options, 'spec', pattern
run_specs options, all_spec_paths, pattern
end
end
end

@ -5,11 +5,11 @@ ar:
button_update: 'التحديث'
avatars:
label_choose_avatar: "Choose Avatar from file"
uploading_avatar: "Uploading your avatar."
uploading_avatar: "تحميل الصورة الرمزية"
text_upload_instructions: |
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.
error_image_too_large: "Image is too large."
error_image_too_large: "الصورة كبيرة جداً."
wrong_file_format: "Allowed formats are 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, -> {
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|
where("#{Version.table_name}.project_id = #{project.id}" +

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

@ -68,16 +68,20 @@ module Bim
end
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
firstline = File.open(file_path, &:readline)
unless firstline.match?(/^ISO-10303-21;/)
errors.add :base, :invalid_ifc_file
end
rescue ArgumentError
errors.add :base, :invalid_ifc_file
ensure
FileUtils.rm file_path if File.exists? file_path
end
end

@ -33,11 +33,12 @@ module Bim
class IfcModelsController < BaseController
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_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
@ -48,9 +49,25 @@ module Bim
def new
@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
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
def show
@ -61,6 +78,70 @@ module Bim
frontend_redirect @ifc_models.defaults.pluck(:id).uniq
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
combined_params = permitted_model_params
.to_h

@ -39,7 +39,7 @@ module Bim
super
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
@ -61,15 +61,23 @@ module Bim
end
def set_title
model.title = model.ifc_attachment&.file&.filename&.gsub(/\.\w+$/, '')
model.title ||= model.ifc_attachment&.file&.filename&.gsub(/\.\w+$/, '')
end
def set_ifc_attachment(ifc_attachment)
return unless ifc_attachment
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'})
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' %>
</div>
<% 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': '' %>">
<% if OpenProject::Configuration.direct_uploads? %>
<input class="form--file-field" type="file" name="file" />
<% else %>
<%= f.file_field :ifc_attachment %>
<% end %>
</div>
<div class="form--field">
<%= f.check_box 'is_default' %>
</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'
bcf:
label_bcf: 'BCF'
label_imported_failed: 'Failed imports of BCF topics'
label_imported_successfully: 'Successfully imported BCF topics'
issues: "Issues"
recommended: 'recommended'
not_recommended: 'not recommended'
no_viewpoints: 'No viewpoints'
label_imported_failed: 'فشل استيراد مواضيع BCF'
label_imported_successfully: 'تم استيراد موضوعات BCF بنجاح'
issues: "مشاكل"
recommended: 'موصى بها'
not_recommended: 'غير موصى بها'
no_viewpoints: 'لا توجد وجهات نظر'
new_badge: "جديد"
exceptions:
file_invalid: "BCF file invalid"
file_invalid: "ملف BCF غير صالح"
x_bcf_issues:
zero: 'No BCF issues'
zero: 'لا توجد مشاكل BCF'
zero: '%{count} BCF issues'
one: 'One BCF issue'
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_successful: 'Imported %{count} BCF issues'
import_canceled: 'BCF-XML import canceled.'
type_not_active: "The issue type is not activated for this project."
type_not_active: "لم يتم تفعيل نوع المشكلة لهذا المشروع."
import:
num_issues_found: '%{x_bcf_issues} are contained in the BCF-XML file, their details are listed below.'
button_prepare: 'Prepare import'
button_perform_import: 'Confirm import'
num_issues_found: '%{x_bcf_issues} موجودة في ملف BCF-XML ، وترد تفاصيلها أدناه.'
button_prepare: 'إعداد الاستيراد'
button_perform_import: 'تأكيد الاستيراد'
button_proceed: 'Proceed with import'
button_back_to_list: 'Back to list'
no_permission_to_add_members: 'You do not have sufficient permissions to add them as members to the project.'
contact_project_admin: 'Contact your project admin to add them as members and start this import again.'
continue_anyways: 'Do you want to proceed and finish the import anyways?'
description: "Provide a BCF-XML v2.1 file to import into this project. You can examine its contents before performing the import."
invalid_types_found: 'Invalid topic type names found'
invalid_statuses_found: 'Invalid status names found'
invalid_priorities_found: 'Invalid priority names found'
invalid_emails_found: 'Invalid email addresses found'
unknown_emails_found: 'Unknown email addresses found'
unknown_property: 'Unknown property'
non_members_found: 'Non project members found'
import_types_as: 'Set all these types to'
import_statuses_as: 'Set all these statuses to'
import_priorities_as: 'Set all these priorities to'
invite_as_members_with_role: 'Invite them as members to the project "%{project}" with role'
add_as_members_with_role: 'Add them as members to the project "%{project}" with role'
no_type_provided: 'No type provided'
no_status_provided: 'No status provided'
no_priority_provided: 'No priority provided'
perform_description: "Do you want to import or update the issues listed above?"
replace_with_system_user: 'Replace them with "System" user'
import_as_system_user: 'Import them as "System" user.'
button_back_to_list: 'رجوع إلى القائمة'
no_permission_to_add_members: 'ليس لديك الصلاحيات الكافية لإضافتها كأعضاء في المشروع.'
contact_project_admin: 'اتصل بمشرف المشروع الخاص بك لإضافته كأعضاء وبدء هذا الاستيراد مرة أخرى.'
continue_anyways: 'هل تريد المضي قدما وإنهاء الاستيراد على أي حال؟'
description: "توفير ملف BCF-XML v2.1 للاستيراد إلى هذا المشروع. يمكنك فحص محتوياته قبل إجراء الاستيراد."
invalid_types_found: 'تم العثور على أسماء غير صالحة لنوع الموضوع'
invalid_statuses_found: 'تم العثور على أسماء غير صالحة لنوع الموضوع'
invalid_priorities_found: 'تم العثور على أسماء غير صالحة لنوع الموضوع'
invalid_emails_found: 'عنوان البريد الإلكتروني غير صالح'
unknown_emails_found: 'عنوان البريد الإلكتروني غير صالح'
unknown_property: 'خاصية غير معروفة'
non_members_found: 'لم يتم العثور على أعضاء المشروع'
import_types_as: 'تعيين جميع هذه الأنواع إلى'
import_statuses_as: 'تعيين جميع هذه الحالات إلى'
import_priorities_as: 'تعيين جميع هذه الأولويات إلى'
invite_as_members_with_role: 'قم بدعوتهم كأعضاء في المشروع "%{project}" مع دور'
add_as_members_with_role: 'قم بدعوتهم كأعضاء في المشروع "%{project}" مع دور'
no_type_provided: 'لا يوجد نوع'
no_status_provided: 'لا توجد حالة'
no_priority_provided: 'لا توجد أولوية'
perform_description: "هل تريد استيراد أو تحديث المشكلات المدرجة أعلاه؟"
replace_with_system_user: 'استبدالها بمستخدم "النظام"'
import_as_system_user: 'استيرادها كمستخدم "النظام".'
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."
export:
format:
@ -69,13 +69,13 @@ ar:
bcf_thumbnail: "BCF snapshot"
project_module_bcf: "BCF"
project_module_bim: "BCF"
permission_view_linked_issues: "View BCF issues"
permission_manage_bcf: "Import and manage BCF issues"
permission_view_linked_issues: "لا توجد مشاكل BCF"
permission_manage_bcf: "استيراد وإدارة مشكلات BCF"
permission_delete_bcf: "Delete BCF issues"
oauth:
scopes:
bcf_v2_1: "Full access to the 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: "الوصول الكامل إلى BCF v2.1 API"
bcf_v2_1_text: "سيحصل التطبيق على الوصول الكامل للقراءة والكتابة إلى OpenProject BCF API v2.1 لتنفيذ الإجراءات نيابة عنك."
activerecord:
models:
bim/ifc_models/ifc_model: "IFC model"

@ -101,7 +101,7 @@ es:
snapshot_data_blank: "«snapshot_data» tiene que especificarse."
unsupported_key: "Se ha incluido una propiedad JSON no admitida."
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:
label_ifc_models: 'Modelos IFC'
label_new_ifc_model: 'Nuevo modelo IFC'

@ -18,8 +18,8 @@ es:
manage: 'Administrar modelos'
views:
viewer: 'Visor'
split: 'Viewer and table'
split: 'Visor y tabla'
split_cards: 'Visor y tarjetas'
revit:
revit_add_in: "Revit Add-In"
revit_add_in_settings: "Revit Add-In settings"
revit_add_in: "Revisar agregado"
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