diff --git a/.travis.yml b/.travis.yml
index 4c134da9a9..7468f0318d 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -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
diff --git a/Gemfile b/Gemfile
index 7e51a1cde7..9e830a1b7f 100644
--- a/Gemfile
+++ b/Gemfile
@@ -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'
diff --git a/Gemfile.lock b/Gemfile.lock
index e41cad4613..a7219c9b47 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -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)
diff --git a/app/models/attachment.rb b/app/models/attachment.rb
index a44d18cd1e..7be64b9dcd 100644
--- a/app/models/attachment.rb
+++ b/app/models/attachment.rb
@@ -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
diff --git a/app/models/queries/work_packages/filter/estimated_hours_filter.rb b/app/models/queries/work_packages/filter/estimated_hours_filter.rb
index edffef63e4..17c375982b 100644
--- a/app/models/queries/work_packages/filter/estimated_hours_filter.rb
+++ b/app/models/queries/work_packages/filter/estimated_hours_filter.rb
@@ -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
diff --git a/app/models/version.rb b/app/models/version.rb
index 30091938fc..810cab1832 100644
--- a/app/models/version.rb
+++ b/app/models/version.rb
@@ -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')
diff --git a/app/uploaders/direct_fog_uploader.rb b/app/uploaders/direct_fog_uploader.rb
new file mode 100644
index 0000000000..037a3cea8b
--- /dev/null
+++ b/app/uploaders/direct_fog_uploader.rb
@@ -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
diff --git a/app/workers/attachments/cleanup_uncontainered_job.rb b/app/workers/attachments/cleanup_uncontainered_job.rb
index 7c599a3b0c..5bba5cff87 100644
--- a/app/workers/attachments/cleanup_uncontainered_job.rb
+++ b/app/workers/attachments/cleanup_uncontainered_job.rb
@@ -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
diff --git a/app/workers/attachments/finish_direct_upload_job.rb b/app/workers/attachments/finish_direct_upload_job.rb
new file mode 100644
index 0000000000..f4b0cd874a
--- /dev/null
+++ b/app/workers/attachments/finish_direct_upload_job.rb
@@ -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
diff --git a/config/initializers/carrierwave.rb b/config/initializers/carrierwave.rb
index dab69c87e2..49dcc4a54d 100644
--- a/config/initializers/carrierwave.rb
+++ b/config/initializers/carrierwave.rb
@@ -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
diff --git a/config/initializers/secure_headers.rb b/config/initializers/secure_headers.rb
index aeffb0ce84..dfb343a85b 100644
--- a/config/initializers/secure_headers.rb
+++ b/config/initializers/secure_headers.rb
@@ -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'),
diff --git a/config/locales/crowdin/ar.yml b/config/locales/crowdin/ar.yml
index ceffc74618..1c0cd89037 100644
--- a/config/locales/crowdin/ar.yml
+++ b/config/locales/crowdin/ar.yml
@@ -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: "الوقت المُقّدَّر"
diff --git a/config/locales/crowdin/bg.yml b/config/locales/crowdin/bg.yml
index 7aef3e31fe..6c47b9e494 100644
--- a/config/locales/crowdin/bg.yml
+++ b/config/locales/crowdin/bg.yml
@@ -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: "Очаквано време"
diff --git a/config/locales/crowdin/ca.yml b/config/locales/crowdin/ca.yml
index 766793fd27..cee8b680ea 100644
--- a/config/locales/crowdin/ca.yml
+++ b/config/locales/crowdin/ca.yml
@@ -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"
diff --git a/config/locales/crowdin/cs.yml b/config/locales/crowdin/cs.yml
index d27295c88a..831a3c3806 100644
--- a/config/locales/crowdin/cs.yml
+++ b/config/locales/crowdin/cs.yml
@@ -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"
diff --git a/config/locales/crowdin/da.yml b/config/locales/crowdin/da.yml
index 1d7ff936ef..820d5e2c27 100644
--- a/config/locales/crowdin/da.yml
+++ b/config/locales/crowdin/da.yml
@@ -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"
diff --git a/config/locales/crowdin/de.yml b/config/locales/crowdin/de.yml
index 6bd3e67ea6..1d7a9b98f9 100644
--- a/config/locales/crowdin/de.yml
+++ b/config/locales/crowdin/de.yml
@@ -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"
diff --git a/config/locales/crowdin/el.yml b/config/locales/crowdin/el.yml
index 8f1540435c..28e367a716 100644
--- a/config/locales/crowdin/el.yml
+++ b/config/locales/crowdin/el.yml
@@ -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: "Εκτιμώμενος χρόνος"
diff --git a/config/locales/crowdin/es.yml b/config/locales/crowdin/es.yml
index 0bd8757020..42e1188e3b 100644
--- a/config/locales/crowdin/es.yml
+++ b/config/locales/crowdin/es.yml
@@ -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
+ Este formulario LDAP requiere conocimientos técnicos para la configuración de su LDAP / Directorio Activo 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 si esta presente 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.
- 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"
diff --git a/config/locales/crowdin/fi.yml b/config/locales/crowdin/fi.yml
index f62c6b6510..b342b2ce10 100644
--- a/config/locales/crowdin/fi.yml
+++ b/config/locales/crowdin/fi.yml
@@ -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"
diff --git a/config/locales/crowdin/fil.yml b/config/locales/crowdin/fil.yml
index 6fa42e3a1b..481868e22d 100644
--- a/config/locales/crowdin/fil.yml
+++ b/config/locales/crowdin/fil.yml
@@ -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"
diff --git a/config/locales/crowdin/fr.yml b/config/locales/crowdin/fr.yml
index df1d7cadef..e747e39553 100644
--- a/config/locales/crowdin/fr.yml
+++ b/config/locales/crowdin/fr.yml
@@ -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"
diff --git a/config/locales/crowdin/hr.yml b/config/locales/crowdin/hr.yml
index ae2875301a..93cc31f7cb 100644
--- a/config/locales/crowdin/hr.yml
+++ b/config/locales/crowdin/hr.yml
@@ -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"
diff --git a/config/locales/crowdin/hu.yml b/config/locales/crowdin/hu.yml
index 4593192d48..4f96408b2f 100644
--- a/config/locales/crowdin/hu.yml
+++ b/config/locales/crowdin/hu.yml
@@ -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)"
diff --git a/config/locales/crowdin/id.yml b/config/locales/crowdin/id.yml
index 075805b943..ed18b8b4b0 100644
--- a/config/locales/crowdin/id.yml
+++ b/config/locales/crowdin/id.yml
@@ -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"
diff --git a/config/locales/crowdin/it.yml b/config/locales/crowdin/it.yml
index 84f83fdafb..c51cfb08b5 100644
--- a/config/locales/crowdin/it.yml
+++ b/config/locales/crowdin/it.yml
@@ -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"
diff --git a/config/locales/crowdin/ja.yml b/config/locales/crowdin/ja.yml
index 539f19b9b7..be2f987bfb 100644
--- a/config/locales/crowdin/ja.yml
+++ b/config/locales/crowdin/ja.yml
@@ -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: "予定工数"
diff --git a/config/locales/crowdin/js-es.yml b/config/locales/crowdin/js-es.yml
index 8401708436..8236cb1884 100644
--- a/config/locales/crowdin/js-es.yml
+++ b/config/locales/crowdin/js-es.yml
@@ -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 Términos del servicio y la Política de privacidad.
@@ -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."
diff --git a/config/locales/crowdin/ko.yml b/config/locales/crowdin/ko.yml
index 6ba1049126..237b3bdf12 100644
--- a/config/locales/crowdin/ko.yml
+++ b/config/locales/crowdin/ko.yml
@@ -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: "예상된 시간"
diff --git a/config/locales/crowdin/lt.yml b/config/locales/crowdin/lt.yml
index 1b041c2a4f..c2a4b5717a 100644
--- a/config/locales/crowdin/lt.yml
+++ b/config/locales/crowdin/lt.yml
@@ -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ė"
diff --git a/config/locales/crowdin/nl.yml b/config/locales/crowdin/nl.yml
index aa017dc49b..7c37fde939 100644
--- a/config/locales/crowdin/nl.yml
+++ b/config/locales/crowdin/nl.yml
@@ -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"
diff --git a/config/locales/crowdin/no.yml b/config/locales/crowdin/no.yml
index 62def70146..daa13b358b 100644
--- a/config/locales/crowdin/no.yml
+++ b/config/locales/crowdin/no.yml
@@ -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"
diff --git a/config/locales/crowdin/pl.yml b/config/locales/crowdin/pl.yml
index d9ec1621b5..d05ea49ef4 100644
--- a/config/locales/crowdin/pl.yml
+++ b/config/locales/crowdin/pl.yml
@@ -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"
diff --git a/config/locales/crowdin/pt.yml b/config/locales/crowdin/pt.yml
index 352996a47e..25c2207733 100644
--- a/config/locales/crowdin/pt.yml
+++ b/config/locales/crowdin/pt.yml
@@ -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"
diff --git a/config/locales/crowdin/ro.yml b/config/locales/crowdin/ro.yml
index 7322769df2..cc8a33dcaf 100644
--- a/config/locales/crowdin/ro.yml
+++ b/config/locales/crowdin/ro.yml
@@ -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ă"
diff --git a/config/locales/crowdin/ru.yml b/config/locales/crowdin/ru.yml
index b468e95228..e88450778d 100644
--- a/config/locales/crowdin/ru.yml
+++ b/config/locales/crowdin/ru.yml
@@ -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: "Предполагаемое время"
diff --git a/config/locales/crowdin/sk.yml b/config/locales/crowdin/sk.yml
index 07fdc886e9..7950432d4b 100644
--- a/config/locales/crowdin/sk.yml
+++ b/config/locales/crowdin/sk.yml
@@ -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"
diff --git a/config/locales/crowdin/sl.yml b/config/locales/crowdin/sl.yml
index 0847eae63b..8e706451da 100644
--- a/config/locales/crowdin/sl.yml
+++ b/config/locales/crowdin/sl.yml
@@ -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"
diff --git a/config/locales/crowdin/sv.yml b/config/locales/crowdin/sv.yml
index 1f9ae804ab..fd20ec633c 100644
--- a/config/locales/crowdin/sv.yml
+++ b/config/locales/crowdin/sv.yml
@@ -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"
diff --git a/config/locales/crowdin/tr.yml b/config/locales/crowdin/tr.yml
index f9ffaf5ff0..a6c2f13c95 100644
--- a/config/locales/crowdin/tr.yml
+++ b/config/locales/crowdin/tr.yml
@@ -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"
diff --git a/config/locales/crowdin/uk.yml b/config/locales/crowdin/uk.yml
index 42aedad680..9143bb9e04 100644
--- a/config/locales/crowdin/uk.yml
+++ b/config/locales/crowdin/uk.yml
@@ -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: "Час (приблизно)"
diff --git a/config/locales/crowdin/vi.yml b/config/locales/crowdin/vi.yml
index 6940f4b221..fb347c7ad4 100644
--- a/config/locales/crowdin/vi.yml
+++ b/config/locales/crowdin/vi.yml
@@ -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"
diff --git a/config/locales/crowdin/zh-CN.yml b/config/locales/crowdin/zh-CN.yml
index 8d81e13e35..c09004d505 100644
--- a/config/locales/crowdin/zh-CN.yml
+++ b/config/locales/crowdin/zh-CN.yml
@@ -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: "估计的时间"
diff --git a/config/locales/crowdin/zh-TW.yml b/config/locales/crowdin/zh-TW.yml
index 96555d4a3b..f3c277e81e 100644
--- a/config/locales/crowdin/zh-TW.yml
+++ b/config/locales/crowdin/zh-TW.yml
@@ -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: "預估時間"
diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml
index 79d1996497..1c01f1134a 100644
--- a/config/locales/js-en.yml
+++ b/config/locales/js-en.yml
@@ -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"
diff --git a/docs/development/running-tests/README.md b/docs/development/running-tests/README.md
index d3ee9df1aa..5df82f4351 100644
--- a/docs/development/running-tests/README.md
+++ b/docs/development/running-tests/README.md
@@ -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
diff --git a/docs/installation-and-operations/configuration/README.md b/docs/installation-and-operations/configuration/README.md
index aa12eea045..c8af4b70bc 100644
--- a/docs/installation-and-operations/configuration/README.md
+++ b/docs/installation-and-operations/configuration/README.md
@@ -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
diff --git a/frontend/src/app/angular4-modules.ts b/frontend/src/app/angular4-modules.ts
index 65a6f77046..25338624f9 100644
--- a/frontend/src/app/angular4-modules.ts
+++ b/frontend/src/app/angular4-modules.ts
@@ -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,
diff --git a/frontend/src/app/components/api/api-v3/api-v3-filter-builder.ts b/frontend/src/app/components/api/api-v3/api-v3-filter-builder.ts
index 64b38cf6a9..e9ff8ad34e 100644
--- a/frontend/src/app/components/api/api-v3/api-v3-filter-builder.ts
+++ b/frontend/src/app/components/api/api-v3/api-v3-filter-builder.ts
@@ -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
diff --git a/frontend/src/app/components/api/op-file-upload/op-direct-file-upload.service.ts b/frontend/src/app/components/api/op-file-upload/op-direct-file-upload.service.ts
new file mode 100644
index 0000000000..cf2c68d542
--- /dev/null
+++ b/frontend/src/app/components/api/op-file-upload/op-direct-file-upload.service.ts
@@ -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> {
+ return result => {
+ result.form.append('file', file, file.customName || file.name);
+
+ return this
+ .http
+ .request(
+ 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) => Observable> {
+ 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 {
+ 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(
+ "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;
+ }
+}
diff --git a/frontend/src/app/components/api/op-file-upload/op-file-upload.service.spec.ts b/frontend/src/app/components/api/op-file-upload/op-file-upload.service.spec.ts
index 7d0d5716f3..d65743c14f 100644
--- a/frontend/src/app/components/api/op-file-upload/op-file-upload.service.spec.ts
+++ b/frontend/src/app/components/api/op-file-upload/op-file-upload.service.spec.ts
@@ -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
]
});
diff --git a/frontend/src/app/components/wp-edit-form/work-package-filter-values.spec.ts b/frontend/src/app/components/wp-edit-form/work-package-filter-values.spec.ts
index 35038b99a1..c548f7b677 100644
--- a/frontend/src/app/components/wp-edit-form/work-package-filter-values.spec.ts
+++ b/frontend/src/app/components/wp-edit-form/work-package-filter-values.spec.ts
@@ -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,
diff --git a/frontend/src/app/helpers/rxjs/debounced-input-switchmap.ts b/frontend/src/app/helpers/rxjs/debounced-input-switchmap.ts
index 6b2294d0e1..5a9dfb5078 100644
--- a/frontend/src/app/helpers/rxjs/debounced-input-switchmap.ts
+++ b/frontend/src/app/helpers/rxjs/debounced-input-switchmap.ts
@@ -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 {
/**
* @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,
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 {
this.lastResult = results;
})
)
- )
+ ),
+ shareReplay(1)
)
);
}
diff --git a/frontend/src/app/modules/apiv3/endpoints/work_packages/work-package-cache.spec.ts b/frontend/src/app/modules/apiv3/endpoints/work_packages/work-package-cache.spec.ts
index 6c342ac97e..32078c47b5 100644
--- a/frontend/src/app/modules/apiv3/endpoints/work_packages/work-package-cache.spec.ts
+++ b/frontend/src/app/modules/apiv3/endpoints/work_packages/work-package-cache.spec.ts
@@ -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: {}},
]
});
diff --git a/frontend/src/app/modules/boards/board/add-list-modal/add-list-modal.component.ts b/frontend/src/app/modules/boards/board/add-list-modal/add-list-modal.component.ts
index 3f7f52e901..da22267bca 100644
--- a/frontend/src/app/modules/boards/board/add-list-modal/add-list-modal.component.ts
+++ b/frontend/src/app/modules/boards/board/add-list-modal/add-list-modal.component.ts
@@ -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(
+ (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) {
@@ -115,18 +129,25 @@ export class AddListModalComponent extends OpModalComponent implements OnInit {
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;
- });
- }
+ .warningTextWhenNoOptionsAvailable()
+ .then((text) => {
+ this.warningText = text;
+ });
+
+ 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();
}
diff --git a/frontend/src/app/modules/boards/board/add-list-modal/add-list-modal.html b/frontend/src/app/modules/boards/board/add-list-modal/add-list-modal.html
index 2c38fd3786..cc091129e5 100644
--- a/frontend/src/app/modules/boards/board/add-list-modal/add-list-modal.html
+++ b/frontend/src/app/modules/boards/board/add-list-modal/add-list-modal.html
@@ -14,12 +14,20 @@
+
+
+
+
+
+
-
-
-
-
-
-
<% end %>
+
+<% if OpenProject::Configuration.direct_uploads? %>
+ <% Hash(@form).each do |key, value| %>
+
+ <% end %>
+<% end %>
+
+
+<% 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 %>
diff --git a/modules/bim/config/locales/crowdin/ar.yml b/modules/bim/config/locales/crowdin/ar.yml
index a9f1ac21ac..c4f6eb6107 100644
--- a/modules/bim/config/locales/crowdin/ar.yml
+++ b/modules/bim/config/locales/crowdin/ar.yml
@@ -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"
diff --git a/modules/bim/config/locales/crowdin/es.yml b/modules/bim/config/locales/crowdin/es.yml
index 6142b2c199..1638c3a701 100644
--- a/modules/bim/config/locales/crowdin/es.yml
+++ b/modules/bim/config/locales/crowdin/es.yml
@@ -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'
diff --git a/modules/bim/config/locales/crowdin/js-es.yml b/modules/bim/config/locales/crowdin/js-es.yml
index 6519683a65..e0c4212a2a 100644
--- a/modules/bim/config/locales/crowdin/js-es.yml
+++ b/modules/bim/config/locales/crowdin/js-es.yml
@@ -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"
diff --git a/modules/bim/config/routes.rb b/modules/bim/config/routes.rb
index edfeb49978..1174d96f6c 100644
--- a/modules/bim/config/routes.rb
+++ b/modules/bim/config/routes.rb
@@ -46,6 +46,8 @@ OpenProject::Application.routes.draw do
resources :ifc_models, controller: 'bim/ifc_models/ifc_models' do
collection do
get :defaults
+ get :direct_upload_finished
+ post :set_direct_upload_file_name
end
end
end
diff --git a/modules/bim/spec/features/bcf/direct_ifc_upload_spec.rb b/modules/bim/spec/features/bcf/direct_ifc_upload_spec.rb
new file mode 100644
index 0000000000..3a3878a94f
--- /dev/null
+++ b/modules/bim/spec/features/bcf/direct_ifc_upload_spec.rb
@@ -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-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 'spec_helper'
+
+describe 'direct IFC upload', type: :feature, js: true, with_direct_uploads: :redirect, with_config: { edition: 'bim' } do
+ let(:user) { FactoryBot.create :admin }
+ let(:project) { FactoryBot.create :project, enabled_module_names: %i[bim] }
+ let(:ifc_fixture) { Rails.root.join('modules/bim/spec/fixtures/files/minimal.ifc') }
+
+ before do
+ login_as user
+
+ allow_any_instance_of(Bim::IfcModels::BaseContract).to receive(:ifc_attachment_is_ifc).and_return true
+ end
+
+ it 'should work' do
+ visit new_bcf_project_ifc_model_path(project_id: project.identifier)
+
+ page.attach_file("file", ifc_fixture, visible: :all)
+
+ click_on "Create"
+
+ expect(page).to have_content("Upload succeeded")
+
+ expect(Attachment.count).to eq 1
+ expect(Attachment.first[:file]).to eq 'model.ifc'
+
+ expect(Bim::IfcModels::IfcModel.count).to eq 1
+ expect(Bim::IfcModels::IfcModel.first.title).to eq "minimal.ifc"
+ end
+end
diff --git a/modules/boards/config/locales/crowdin/ar.yml b/modules/boards/config/locales/crowdin/ar.yml
index 748d4bbf6c..63c0ff49d7 100644
--- a/modules/boards/config/locales/crowdin/ar.yml
+++ b/modules/boards/config/locales/crowdin/ar.yml
@@ -1,7 +1,7 @@
#English strings go here
ar:
- permission_show_board_views: "View boards"
- permission_manage_board_views: "Manage boards"
+ permission_show_board_views: "شاهد لوحات المهمات"
+ permission_manage_board_views: "إدارة اللوحات"
project_module_board_view: "Boards"
boards:
label_boards: "Boards"
diff --git a/modules/boards/config/locales/crowdin/js-ar.yml b/modules/boards/config/locales/crowdin/js-ar.yml
index 09e34e89e1..944404494a 100644
--- a/modules/boards/config/locales/crowdin/js-ar.yml
+++ b/modules/boards/config/locales/crowdin/js-ar.yml
@@ -2,36 +2,36 @@
ar:
js:
boards:
- label_unnamed_board: 'Unnamed board'
- label_unnamed_list: 'Unnamed list'
- label_board_type: 'Board type'
+ label_unnamed_board: 'لوحة غير مسماة'
+ label_unnamed_list: 'قائمة غير مسماة'
+ label_board_type: 'نوع اللوحة'
upsale:
- teaser_text: 'Improve your agile project management with this flexible Boards view. Create as many boards as you like for anything you would like to keep track of.'
- upgrade_to_ee_text: 'Boards is an Enterprise feature. Please upgrade to a paid plan.'
- upgrade: 'Upgrade now'
- personal_demo: 'Get a personal demo'
+ teaser_text: 'قم بتحسين إدارة مشروعك الرائع مع عرض المجالس المرنة هذا. قم بإنشاء أكبر عدد من اللوحات التي تريدها لأي شيء ترغب في متابعته.'
+ upgrade_to_ee_text: 'الجلسات هي ميزة المؤسسة. الرجاء الترقية إلى خطة مدفوعة.'
+ upgrade: 'الترقية الآن'
+ personal_demo: 'الحصول على عرض تجريبي'
lists:
- delete: 'Delete list'
+ delete: 'حذف القائمة'
version:
- is_locked: 'Version is locked. No items can be added to this version.'
- is_closed: 'Version is closed. No items can be added to this version.'
- close_version: 'Close version'
- open_version: 'Open version'
+ is_locked: 'الإصدار مقفل. لا يمكن إضافة أي عناصر إلى هذا الإصدار.'
+ is_closed: 'الإصدار مقفل. لا يمكن إضافة أي عناصر إلى هذا الإصدار.'
+ close_version: 'النسخة الأساسية'
+ open_version: 'إصدار جديد'
lock_version: 'Lock version'
unlock_version: 'Unlock version'
edit_version: 'Edit version'
- show_version: 'Show version'
+ show_version: 'إظهار الإصدار'
locked: 'مقفل'
closed: 'مغلق'
- new_board: 'New board'
- add_list: 'Add list'
- add_card: 'Add card'
- error_attribute_not_writable: "Cannot move the work package, %{attribute} is not writable."
- error_loading_the_list: "Error loading the list: %{error_message}"
- error_permission_missing: "The permission to create public queries is missing"
- click_to_remove_list: "Click to remove this list"
+ new_board: 'لوحة جديدة'
+ add_list: 'إنشاء قائمة'
+ add_card: 'إضافة بطاقة'
+ error_attribute_not_writable: "لا يمكن نقل حزمة العمل، %{attribute} غير قابل للكتابة."
+ error_loading_the_list: "خطأ في تحميل القائمة: %{error_message}"
+ error_permission_missing: "إذن إنشاء استفسارات عامة مفقود"
+ click_to_remove_list: "انقر لإزالة هذه القائمة"
board_type:
- text: 'Board type'
+ text: 'نوع اللوحة'
free: 'Basic board'
select_board_type: 'Please choose the type of board you need.'
free_text: >
diff --git a/modules/boards/config/locales/crowdin/js-es.yml b/modules/boards/config/locales/crowdin/js-es.yml
index cae17cbd5a..fa6efb5ee7 100644
--- a/modules/boards/config/locales/crowdin/js-es.yml
+++ b/modules/boards/config/locales/crowdin/js-es.yml
@@ -33,18 +33,18 @@ es:
board_type:
text: 'Tipo de panel'
free: 'Panel básico'
- select_board_type: 'Please choose the type of board you need.'
+ select_board_type: 'Por favor elija el tipo de tablero que necesite.'
free_text: >
Cree un panel donde pueda crear libremente listas y ordenar los paquetes de trabajo que contenga. Al mover los paquetes de trabajo entre las listas, no se cambia del paquete de trabajo en sí.
action: 'Panel de acciones'
action_by_attribute: 'Panel de acciones (%{attribute})'
action_text: >
- Create a board with filtered lists on %{attribute} attribute. Moving work packages to other lists will update their attribute.
+ Crear un tablero con listas filtradas en el atributo %{attribute} . Moviendo los paquetes de trabajo a otras listas actualizará su atributo.
action_type:
assignee: asignado
status: estado
version: versión
- subproject: subproject
+ subproject: Subproyecto
select_attribute: "Atributo de acción"
add_list_modal:
warning:
diff --git a/modules/boards/config/locales/crowdin/js-lt.yml b/modules/boards/config/locales/crowdin/js-lt.yml
index cb302a7ef1..7e683a787e 100644
--- a/modules/boards/config/locales/crowdin/js-lt.yml
+++ b/modules/boards/config/locales/crowdin/js-lt.yml
@@ -33,18 +33,18 @@ lt:
board_type:
text: 'Lentos tipas'
free: 'Paprasta lenta'
- select_board_type: 'Please choose the type of board you need.'
+ select_board_type: 'Prašome pasirinkti reikalingą lentos tipą.'
free_text: >
Sukurkite lentą, kurioje galėsite laisvai kurti sąrašus, o juose rikiuoti darbų paketus. Darbo paketo perkėlimas tarp sąrašų visiškai nekeis darbo paketo.
action: 'Veiksmų lenta'
action_by_attribute: 'Veiksmų lenta (%{attribute})'
action_text: >
- Create a board with filtered lists on %{attribute} attribute. Moving work packages to other lists will update their attribute.
+ Sukurkite lentą su filtruotomis atributo %{attribute} reikšmėmis. Perkeliant darbo paketą tarp sąrašų bus keičiama atributo reikšmė.
action_type:
assignee: paskirtas
status: būsena
version: versija
- subproject: subproject
+ subproject: sub-projektas
select_attribute: "Veiksmo atributas"
add_list_modal:
warning:
diff --git a/modules/boards/config/locales/crowdin/js-ru.yml b/modules/boards/config/locales/crowdin/js-ru.yml
index 4969592bc2..d5e8f0bd82 100644
--- a/modules/boards/config/locales/crowdin/js-ru.yml
+++ b/modules/boards/config/locales/crowdin/js-ru.yml
@@ -33,18 +33,18 @@ ru:
board_type:
text: 'Тип доски'
free: 'Базовая доска'
- select_board_type: 'Please choose the type of board you need.'
+ select_board_type: 'Пожалуйста, выберите требуемый вам тип доски.'
free_text: >
Создайте доску, в которой вы можете свободно создавать списки и заказать пакеты работ внутри. Перемещение пакетов работ между списками не изменяет сам пакет работ.
action: 'Доска действий'
action_by_attribute: 'Доска действий (%{attribute})'
action_text: >
- Create a board with filtered lists on %{attribute} attribute. Moving work packages to other lists will update their attribute.
+ Создать доску с отфильтрованными по атрибуту %{attribute} списками. Перемещение рабочих пакетов в другие списки обновит их атрибут.
action_type:
assignee: правопреемник
status: статус
version: версия
- subproject: subproject
+ subproject: подпроект
select_attribute: "Атрибут действия"
add_list_modal:
warning:
diff --git a/modules/boards/config/locales/crowdin/js-tr.yml b/modules/boards/config/locales/crowdin/js-tr.yml
index 4f0dbd71b2..6abecc9f78 100644
--- a/modules/boards/config/locales/crowdin/js-tr.yml
+++ b/modules/boards/config/locales/crowdin/js-tr.yml
@@ -33,18 +33,18 @@ tr:
board_type:
text: 'Yazı tahtası tipi'
free: 'Temel kurulu'
- select_board_type: 'Please choose the type of board you need.'
+ select_board_type: 'Lütfen ihtiyacınız olan kart tipini seçin.'
free_text: >
Serbestçe listeler oluşturabileceğiniz ve çalışma paketlerinizi içinde sipariş edebileceğiniz bir tahta oluşturun. İş paketlerini listeler arasında taşımak, iş paketini değiştirmez.
action: 'Eylem kurulu'
action_by_attribute: 'Eylem kurulu (%{attribute})'
action_text: >
- Create a board with filtered lists on %{attribute} attribute. Moving work packages to other lists will update their attribute.
+ %{attribute} özniteliğinde filtrelenmiş listeleri olan bir pano oluşturun. Çalışma paketlerini diğer listelere taşımak özniteliklerini güncelleştirir.
action_type:
assignee: vekil
status: durum
version: Sürüm
- subproject: subproject
+ subproject: Alt proje
select_attribute: "Eylem özelliği"
add_list_modal:
warning:
diff --git a/modules/boards/config/locales/js-en.yml b/modules/boards/config/locales/js-en.yml
index 6357391b8a..77eacbae5a 100644
--- a/modules/boards/config/locales/js-en.yml
+++ b/modules/boards/config/locales/js-en.yml
@@ -38,18 +38,25 @@ en:
free: 'Basic board'
select_board_type: 'Please choose the type of board you need.'
free_text: >
- Create a board in which you can freely create lists and order your work packages within.
- Moving work packages between lists do not change the work package itself.
+ A board in which you can freely create lists and order your work packages within.
+ Dragging work packages between lists do not change the work package itself.
action: 'Action board'
action_by_attribute: 'Action board (%{attribute})'
action_text: >
- Create a board with filtered lists on %{attribute} attribute. Moving work packages to other lists
+ A board with filtered lists on %{attribute} attribute. Moving work packages to other lists
will update their attribute.
+ action_text_subprojects: >
+ A board with lists of subprojects of this project and their work packages as list items.
+ Dragging work packages to other lists will move them to the corresponding subproject.
+ action_text_subtasks: >
+ A board with lists of selected parents and their child work packages as list items.
+ Dragging work packages to other lists will update the parent accordingly.
action_type:
assignee: assignee
status: status
version: version
subproject: subproject
+ subtasks: children
select_attribute: "Action attribute"
add_list_modal:
diff --git a/modules/boards/spec/features/action_boards/subproject_board_spec.rb b/modules/boards/spec/features/action_boards/subproject_board_spec.rb
index afc3df21f1..562e234294 100644
--- a/modules/boards/spec/features/action_boards/subproject_board_spec.rb
+++ b/modules/boards/spec/features/action_boards/subproject_board_spec.rb
@@ -159,10 +159,9 @@ describe 'Subproject action board', type: :feature, js: true do
board_page.expect_card('Child 2', 'Task 1', present: true)
# Expect work package to be saved in query second
- sleep 2
retry_block do
- expect(first.reload.ordered_work_packages).to be_empty
- expect(second.reload.ordered_work_packages.count).to eq(1)
+ raise "first should be empty" if first.reload.ordered_work_packages.any?
+ raise "second should have one item" if second.reload.ordered_work_packages.count != 1
end
subjects = WorkPackage.where(id: second.ordered_work_packages.pluck(:work_package_id)).pluck(:subject, :project_id)
diff --git a/modules/boards/spec/features/action_boards/subtasks_board_spec.rb b/modules/boards/spec/features/action_boards/subtasks_board_spec.rb
new file mode 100644
index 0000000000..532634968b
--- /dev/null
+++ b/modules/boards/spec/features/action_boards/subtasks_board_spec.rb
@@ -0,0 +1,153 @@
+#-- 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 'spec_helper'
+require_relative './../support//board_index_page'
+require_relative './../support/board_page'
+
+describe 'Subtasks action board', type: :feature, js: true do
+ let(:type) { FactoryBot.create(:type_standard) }
+ let(:project) { FactoryBot.create(:project, types: [type], enabled_module_names: %i[work_package_tracking board_view]) }
+ let(:role) { FactoryBot.create(:role, permissions: permissions) }
+
+ let(:user) do
+ FactoryBot.create(:user,
+ member_in_project: project,
+ member_through_role: role)
+ end
+
+ let(:board_index) { Pages::BoardIndex.new(project) }
+
+ let!(:priority) { FactoryBot.create :default_priority }
+ let!(:open_status) { FactoryBot.create :default_status, name: 'Open' }
+ let!(:parent) { FactoryBot.create :work_package, project: project, subject: 'Parent WP', status: open_status }
+ let!(:child) { FactoryBot.create :work_package, project: project, subject: 'Child WP', parent: parent, status: open_status }
+
+ before do
+ with_enterprise_token :board_view
+ login_as(user)
+ end
+
+ context 'without the manage_subtasks permission' do
+ let(:permissions) {
+ %i[show_board_views manage_board_views add_work_packages
+ edit_work_packages view_work_packages manage_public_queries]
+ }
+
+ it 'does not allow to move work packages' do
+ board_index.visit!
+
+ # Create new board
+ board_page = board_index.create_board action: :Children, expect_empty: true
+
+ # Expect we can add a work package column
+ board_page.add_list option: 'Parent WP'
+ board_page.expect_list 'Parent WP'
+
+ # Expect one work package there
+ board_page.expect_card 'Parent WP', 'Child'
+ board_page.expect_movable 'Parent WP', 'Child', movable: false
+ end
+ end
+
+ context 'with all permissions' do
+ let!(:other_wp) { FactoryBot.create :work_package, project: project, subject: 'Other WP', status: open_status }
+
+ let(:permissions) {
+ %i[show_board_views manage_board_views add_work_packages
+ edit_work_packages view_work_packages manage_public_queries manage_subtasks]
+ }
+
+ it 'allows management of subtasks work packages' do
+ board_index.visit!
+
+ # Create new board
+ board_page = board_index.create_board action: :Children, expect_empty: true
+
+ # Expect we can add a child 1
+ board_page.add_list option: 'Parent WP'
+ board_page.expect_list 'Parent WP'
+
+ # Expect one work package there
+ board_page.expect_card 'Parent WP', 'Child'
+
+ # Expect move permission to be granted
+ board_page.expect_movable 'Parent WP', 'Child', movable: true
+
+ board_page.board(reload: true) do |board|
+ expect(board.name).to eq 'Action board (children)'
+ queries = board.contained_queries
+ expect(queries.count).to eq(1)
+
+ query = queries.first
+ expect(query.name).to eq 'Parent WP'
+
+ expect(query.filters.first.name).to eq :parent
+ expect(query.filters.first.values).to eq [parent.id.to_s]
+ end
+
+ # Create new list
+ board_page.add_list option: 'Other WP'
+ board_page.expect_list 'Other WP'
+
+ board_page.expect_cards_in_order 'Other WP'
+
+ # Add item
+ board_page.add_card 'Parent WP', 'Second child'
+ sleep 2
+
+ # Expect added to query
+ queries = board_page.board(reload: true).contained_queries
+ expect(queries.count).to eq 2
+ first = queries.find_by(name: 'Parent WP')
+ second = queries.find_by(name: 'Other WP')
+ expect(first.ordered_work_packages.count).to eq(1)
+ expect(second.ordered_work_packages).to be_empty
+
+ # Expect work package to be saved in query first
+ wp = WorkPackage.where(id: first.ordered_work_packages.pluck(:work_package_id)).first
+ expect(wp.parent_id).to eq parent.id
+
+ # Move item to Child 2 list
+ board_page.move_card(0, from: 'Parent WP', to: 'Other WP')
+
+ board_page.expect_card('Parent WP', 'Second child', present: false)
+ board_page.expect_card('Other WP', 'Second child', present: true)
+
+ # Expect work package to be saved in query second
+ sleep 2
+ retry_block do
+ expect(first.reload.ordered_work_packages).to be_empty
+ expect(second.reload.ordered_work_packages.count).to eq(1)
+ end
+
+ wp = WorkPackage.where(id: second.ordered_work_packages.pluck(:work_package_id)).first
+ expect(wp.parent_id).to eq other_wp.id
+ end
+ end
+end
diff --git a/modules/boards/spec/features/support/board_index_page.rb b/modules/boards/spec/features/support/board_index_page.rb
index 0fd4f1dd10..e0052a5ca2 100644
--- a/modules/boards/spec/features/support/board_index_page.rb
+++ b/modules/boards/spec/features/support/board_index_page.rb
@@ -62,7 +62,7 @@ module Pages
if action == nil
find('.tile-block', text: 'Basic Board').click
else
- find('.tile-block', text: action.to_s).click
+ find('.tile-block', text: "Action Board (#{action.to_s})").click
end
if expect_empty
diff --git a/modules/grids/app/controllers/api/v3/attachments/attachments_by_grid_api.rb b/modules/grids/app/controllers/api/v3/attachments/attachments_by_grid_api.rb
index c9222df66f..880a7ee991 100644
--- a/modules/grids/app/controllers/api/v3/attachments/attachments_by_grid_api.rb
+++ b/modules/grids/app/controllers/api/v3/attachments/attachments_by_grid_api.rb
@@ -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
diff --git a/modules/grids/config/locales/crowdin/js-es.yml b/modules/grids/config/locales/crowdin/js-es.yml
index aeaa7eaead..13b14fc2e9 100644
--- a/modules/grids/config/locales/crowdin/js-es.yml
+++ b/modules/grids/config/locales/crowdin/js-es.yml
@@ -3,7 +3,7 @@ es:
grid:
add_widget: 'Agregar widget'
remove: 'Quitar widget'
- configure: 'Configure widget'
+ configure: 'Configurar widget'
upsale:
text: "Algunos widgets, como el widget del gráfico de paquetes de trabajo, solo están disponibles en la "
link: 'edición Enterprise.'
@@ -40,7 +40,7 @@ es:
no_results: 'No hay subproyectos.'
time_entries_current_user:
title: 'Tiempo que he invertido'
- displayed_days: 'Days displayed in the widget:'
+ displayed_days: 'Días mostrados en el widget:'
time_entries_list:
title: 'Tiempo de gastos (últimos 7 días)'
no_results: 'Sin entradas temporales en los últimos 7 días.'
diff --git a/modules/job_status/config/locales/crowdin/js-es.yml b/modules/job_status/config/locales/crowdin/js-es.yml
index 9e3af4cf5f..5751a26df1 100644
--- a/modules/job_status/config/locales/crowdin/js-es.yml
+++ b/modules/job_status/config/locales/crowdin/js-es.yml
@@ -1,14 +1,14 @@
es:
js:
job_status:
- download_starts: 'The download should start automatically.'
- click_to_download: 'Or click here to download.'
- title: 'Background job status'
- redirect: 'You are being redirected.'
+ download_starts: 'La descarga debería iniciar automáticamente.'
+ click_to_download: 'O haga click aquí para descargar.'
+ title: 'Estado de trabajo en segundo plano.'
+ redirect: 'Usted está siendo redirigido.'
generic_messages:
- not_found: 'This job could not be found.'
- in_queue: 'The job has been queued and will be processed shortly.'
- in_process: 'The job is currently being processed.'
- error: 'The job has failed to complete.'
- cancelled: 'The job has been cancelled due to an error.'
- success: 'The job completed successfully.'
+ not_found: 'No se pudo encontrar esta tarea.'
+ in_queue: 'La tarea se ha puesto en cola y se procesará en breve.'
+ in_process: 'La tarea está siendo procesado.'
+ error: 'La tarea no se pudo completar.'
+ cancelled: 'La tarea se cancelo debido a un error.'
+ success: 'La tarea se se completo con éxito.'
diff --git a/modules/meeting/lib/api/v3/attachments/attachments_by_meeting_content_api.rb b/modules/meeting/lib/api/v3/attachments/attachments_by_meeting_content_api.rb
index 70a2a15d5e..5e92b87925 100644
--- a/modules/meeting/lib/api/v3/attachments/attachments_by_meeting_content_api.rb
+++ b/modules/meeting/lib/api/v3/attachments/attachments_by_meeting_content_api.rb
@@ -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
diff --git a/modules/reporting/config/locales/crowdin/es.yml b/modules/reporting/config/locales/crowdin/es.yml
index e22199739a..5edf832e7c 100644
--- a/modules/reporting/config/locales/crowdin/es.yml
+++ b/modules/reporting/config/locales/crowdin/es.yml
@@ -22,7 +22,7 @@
es:
button_save_as: "Guardar informe como..."
comments: "Comentario"
- cost_reports_title: "Time and costs"
+ cost_reports_title: "Tiempo y costos"
label_cost_report: "Informe costo"
label_cost_report_plural: "Reportes de costo"
description_drill_down: "Ver detalles"
diff --git a/modules/webhooks/lib/open_project/webhooks.rb b/modules/webhooks/lib/open_project/webhooks.rb
index 2f4e59cb46..7863c106e8 100644
--- a/modules/webhooks/lib/open_project/webhooks.rb
+++ b/modules/webhooks/lib/open_project/webhooks.rb
@@ -51,7 +51,7 @@ module OpenProject
def self.register_hook(name, &callback)
raise "A hook named '#{name}' is already registered!" if find(name)
- Rails.logger.warn "hook registered"
+ Rails.logger.debug "incoming webhook registered: #{name}"
hook = Hook.new(name, &callback)
@@registered_hooks << hook
hook
diff --git a/spec/features/work_packages/attachments/attachment_upload_spec.rb b/spec/features/work_packages/attachments/attachment_upload_spec.rb
index f7dc0be610..eccf380d96 100644
--- a/spec/features/work_packages/attachments/attachment_upload_spec.rb
+++ b/spec/features/work_packages/attachments/attachment_upload_spec.rb
@@ -77,57 +77,86 @@ describe 'Upload attachment to work package', js: true do
end
context 'on a new page' do
- let!(:new_page) { Pages::FullWorkPackageCreate.new }
- let!(:type) { FactoryBot.create(:type_task) }
- let!(:status) { FactoryBot.create(:status, is_default: true) }
- let!(:priority) { FactoryBot.create(:priority, is_default: true) }
- let!(:project) do
- FactoryBot.create(:project, types: [type])
- end
+ shared_examples 'it supports image uploads via drag & drop' do
+ let!(:new_page) { Pages::FullWorkPackageCreate.new }
+ let!(:type) { FactoryBot.create(:type_task) }
+ let!(:status) { FactoryBot.create(:status, is_default: true) }
+ let!(:priority) { FactoryBot.create(:priority, is_default: true) }
+ let!(:project) do
+ FactoryBot.create(:project, types: [type])
+ end
- before do
- visit new_project_work_packages_path(project.identifier, type: type.id)
- end
+ let(:post_conditions) { nil }
- it 'can upload an image via drag & drop (Regression #28189)' do
- subject = new_page.edit_field :subject
- subject.set_value 'My subject'
+ before do
+ visit new_project_work_packages_path(project.identifier, type: type.id)
+ end
- target = find('.ck-content')
- attachments.drag_and_drop_file(target, image_fixture)
+ it 'can upload an image via drag & drop (Regression #28189)' do
+ subject = new_page.edit_field :subject
+ subject.set_value 'My subject'
- sleep 2
- expect(page).not_to have_selector('notification-upload-progress')
+ target = find('.ck-content')
+ attachments.drag_and_drop_file(target, image_fixture)
- editor.in_editor do |container, editable|
- expect(editable).to have_selector('img[src*="/api/v3/attachments/"]', wait: 20)
- end
+ sleep 2
+ expect(page).not_to have_selector('notification-upload-progress')
- sleep 2
+ editor.in_editor do |container, editable|
+ expect(editable).to have_selector('img[src*="/api/v3/attachments/"]', wait: 20)
+ end
- # Besides testing caption functionality this also slows down clicking on the submit button
- # so that the image is properly embedded
- caption = page.find('figure.image figcaption')
- caption.click(x: 10, y: 10)
- sleep 0.2
- caption.base.send_keys('Some image caption')
+ sleep 2
- sleep 2
+ # Besides testing caption functionality this also slows down clicking on the submit button
+ # so that the image is properly embedded
+ caption = page.find('figure.image figcaption')
+ caption.click(x: 10, y: 10)
+ sleep 0.2
+ caption.base.send_keys('Some image caption')
- scroll_to_and_click find('#work-packages--edit-actions-save')
+ scroll_to_and_click find('#work-packages--edit-actions-save')
- wp_page.expect_notification(
- message: 'Successful creation.'
- )
+ wp_page.expect_notification(
+ message: 'Successful creation.'
+ )
- field = wp_page.edit_field :description
+ field = wp_page.edit_field :description
- expect(field.display_element).to have_selector('img')
- expect(field.display_element).to have_content('Some image caption')
+ expect(field.display_element).to have_selector('img')
+ expect(field.display_element).to have_content('Some image caption')
+
+ wp = WorkPackage.last
+ expect(wp.subject).to eq('My subject')
+ expect(wp.attachments.count).to eq(1)
- wp = WorkPackage.last
- expect(wp.subject).to eq('My subject')
- expect(wp.attachments.count).to eq(1)
+ post_conditions
+ end
+ end
+
+ it_behaves_like 'it supports image uploads via drag & drop'
+
+ # We do a complete integration test for direct uploads on this example.
+ # If this works all parts in the backend and frontend work properly together.
+ # Technically one could test this not only for new work packages, but also for existing
+ # ones, and for new and existing other attachable resources. But the code is the same
+ # everywhere so if this works it should work everywhere else too.
+ context 'with direct uploads', with_direct_uploads: true do
+ before do
+ allow_any_instance_of(Attachment).to receive(:diskfile).and_return Struct.new(:path).new(image_fixture.to_s)
+ end
+
+ it_behaves_like 'it supports image uploads via drag & drop' do
+ let(:post_conditions) do
+ # check the attachment was created successfully
+ expect(Attachment.count).to eq 1
+ a = Attachment.first
+ expect(a[:file]).to eq image_fixture.basename.to_s
+
+ # check /api/v3/attachments/:id/uploaded was called
+ expect(::Attachments::FinishDirectUploadJob).to have_been_enqueued
+ end
+ end
end
end
end
diff --git a/spec/lib/api/v3/attachments/attachment_metadata_representer_spec.rb b/spec/lib/api/v3/attachments/attachment_metadata_representer_spec.rb
index d855840e7d..6c1802b1bd 100644
--- a/spec/lib/api/v3/attachments/attachment_metadata_representer_spec.rb
+++ b/spec/lib/api/v3/attachments/attachment_metadata_representer_spec.rb
@@ -31,14 +31,22 @@ require 'spec_helper'
describe ::API::V3::Attachments::AttachmentMetadataRepresenter do
include API::V3::Utilities::PathHelper
- let(:metadata) {
+ let(:metadata) do
data = Hashie::Mash.new
data.file_name = original_file_name
data.description = original_description
+ data.content_type = original_content_type
+ data.file_size = original_file_size
+ data.digest = original_digest
data
- }
+ end
+
let(:original_file_name) { 'a file name' }
let(:original_description) { 'a description' }
+ let(:original_content_type) { 'text/plain' }
+ let(:original_file_size) { 42 }
+ let(:original_digest) { "0xFF" }
+
let(:representer) { ::API::V3::Attachments::AttachmentMetadataRepresenter.new(metadata) }
describe 'generation' do
@@ -49,6 +57,9 @@ describe ::API::V3::Attachments::AttachmentMetadataRepresenter do
end
it { is_expected.to be_json_eql(original_file_name.to_json).at_path('fileName') }
+ it { is_expected.to be_json_eql(original_content_type.to_json).at_path('contentType') }
+ it { is_expected.to be_json_eql(original_file_size.to_json).at_path('fileSize') }
+ it { is_expected.to be_json_eql(original_digest.to_json).at_path('digest') }
it_behaves_like 'API V3 formattable', 'description' do
let(:format) { 'plain' }
@@ -60,7 +71,10 @@ describe ::API::V3::Attachments::AttachmentMetadataRepresenter do
let(:parsed_hash) {
{
'fileName' => 'the parsed name',
- 'description' => { 'raw' => 'the parsed description' }
+ 'description' => { 'raw' => 'the parsed description' },
+ 'contentType' => 'text/html',
+ 'fileSize' => 43,
+ 'digest' => '0x00'
}
}
@@ -72,5 +86,8 @@ describe ::API::V3::Attachments::AttachmentMetadataRepresenter do
it { expect(subject.file_name).to eql('the parsed name') }
it { expect(subject.description).to eql('the parsed description') }
+ it { expect(subject.content_type).to eql('text/html') }
+ it { expect(subject.file_size).to eql(43) }
+ it { expect(subject.digest).to eql('0x00') }
end
end
diff --git a/spec/lib/open_project/configuration_spec.rb b/spec/lib/open_project/configuration_spec.rb
index 2e264dbf2d..f4bdafff54 100644
--- a/spec/lib/open_project/configuration_spec.rb
+++ b/spec/lib/open_project/configuration_spec.rb
@@ -480,4 +480,39 @@ describe OpenProject::Configuration do
end
end
end
+
+ context 'helpers' do
+ describe '#direct_uploads?' do
+ let(:value) { OpenProject::Configuration.direct_uploads? }
+
+ it 'should be false by default' do
+ expect(value).to be false
+ end
+
+ context 'with remote storage' do
+ def self.storage(provider)
+ {
+ attachments_storage: :fog,
+ fog: {
+ credentials: {
+ provider: provider
+ }
+ }
+ }
+ end
+
+ context 'AWS', with_config: storage('AWS') do
+ it 'should be true' do
+ expect(value).to be true
+ end
+ end
+
+ context 'Azure', with_config: storage('azure') do
+ it 'should be false' do
+ expect(value).to be false
+ end
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/attachment_spec.rb b/spec/models/attachment_spec.rb
index cc8f9a1c53..5e00a5ccf7 100644
--- a/spec/models/attachment_spec.rb
+++ b/spec/models/attachment_spec.rb
@@ -226,39 +226,18 @@ describe Attachment, type: :model do
end
end
- describe "#external_url" do
+ # We just use with_direct_uploads here to make sure the
+ # FogAttachment class is defined and Fog is mocked.
+ describe "#external_url", with_direct_uploads: true do
let(:author) { FactoryBot.create :user }
let(:image_path) { Rails.root.join("spec/fixtures/files/image.png") }
let(:text_path) { Rails.root.join("spec/fixtures/files/testfile.txt") }
let(:binary_path) { Rails.root.join("spec/fixtures/files/textfile.txt.gz") }
- let(:fog_attachment_class) do
- class FogAttachment < Attachment
- # Remounting the uploader overrides the original file setter taking care of setting,
- # among other things, the content type. So we have to restore that original
- # method this way.
- # We do this in a new, separate class, as to not interfere with any other specs.
- alias_method :set_file, :file=
- mount_uploader :file, FogFileUploader
- alias_method :file=, :set_file
- end
-
- FogAttachment
- end
-
- let(:image_attachment) { fog_attachment_class.new author: author, file: File.open(image_path) }
- let(:text_attachment) { fog_attachment_class.new author: author, file: File.open(text_path) }
- let(:binary_attachment) { fog_attachment_class.new author: author, file: File.open(binary_path) }
-
- before do
- Fog.mock!
-
- connection = Fog::Storage.new provider: "AWS"
- connection.directories.create key: "my-bucket"
-
- CarrierWave::Configuration.configure_fog! credentials: {}, directory: "my-bucket", public: false
- end
+ let(:image_attachment) { FogAttachment.new author: author, file: File.open(image_path) }
+ let(:text_attachment) { FogAttachment.new author: author, file: File.open(text_path) }
+ let(:binary_attachment) { FogAttachment.new author: author, file: File.open(binary_path) }
shared_examples "it has a temporary download link" do
let(:url_options) { {} }
diff --git a/spec/models/queries/work_packages/filter/estimated_hours_filter_spec.rb b/spec/models/queries/work_packages/filter/estimated_hours_filter_spec.rb
index 96d87bd64c..e48519c483 100644
--- a/spec/models/queries/work_packages/filter/estimated_hours_filter_spec.rb
+++ b/spec/models/queries/work_packages/filter/estimated_hours_filter_spec.rb
@@ -46,5 +46,21 @@ describe Queries::WorkPackages::Filter::EstimatedHoursFilter, type: :model do
end
it_behaves_like 'non ar filter'
+
+ describe '#where' do
+ let!(:work_package_zero_hour) {FactoryBot.create(:work_package, estimated_hours: 0)}
+ let!(:work_package_no_hours) {FactoryBot.create(:work_package, estimated_hours: nil)}
+ let!(:work_package_with_hours) {FactoryBot.create(:work_package, estimated_hours: 1)}
+
+ context 'with the operator being "none"' do
+ before do
+ instance.operator = Queries::Operators::None.to_sym.to_s
+ end
+ it 'finds zero and none values' do
+ expect(WorkPackage.where(instance.where)).to match_array [work_package_zero_hour, work_package_no_hours]
+ end
+ end
+ end
end
+
end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index e2ce744046..cdc56ee527 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -47,6 +47,12 @@ require 'test_prof/recipes/rspec/before_all'
# directory. Alternatively, in the individual `*_spec.rb` files, manually
# require only the support files necessary.
#
+# The files are sorted before requiring them to ensure the load order is the same
+# everywhere. There are certain helpers that depend on a expected order.
+# The CI may load the files in a different order than what you see locally which
+# may lead to broken specs on the CI, if we don't sort here
+# (example: with_config.rb has to precede with_direct_uploads.rb).
+#
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
Dir[Rails.root.join('spec/features/support/**/*.rb')].each { |f| require f }
Dir[Rails.root.join('spec/lib/api/v3/support/**/*.rb')].each { |f| require f }
diff --git a/spec/requests/api/v3/attachments/attachment_resource_shared_examples.rb b/spec/requests/api/v3/attachments/attachment_resource_shared_examples.rb
index 75e485db8a..cf4a6fbb82 100644
--- a/spec/requests/api/v3/attachments/attachment_resource_shared_examples.rb
+++ b/spec/requests/api/v3/attachments/attachment_resource_shared_examples.rb
@@ -29,6 +29,112 @@
require 'spec_helper'
require 'rack/test'
+shared_examples 'it supports direct uploads' do
+ include Rack::Test::Methods
+ include API::V3::Utilities::PathHelper
+ include FileHelpers
+
+ let(:container_href) { raise "define me!" }
+ let(:request_path) { raise "define me!" }
+
+ before do
+ allow(User).to receive(:current).and_return current_user
+ end
+
+ describe 'POST /prepare', with_settings: { attachment_max_size: 512 } do
+ let(:request_parts) { { metadata: metadata, file: file } }
+ let(:metadata) { { fileName: 'cat.png', fileSize: file.size }.to_json }
+ let(:file) { mock_uploaded_file(name: 'original-filename.txt') }
+
+ def request!
+ post request_path, request_parts
+ end
+
+ subject(:response) { last_response }
+
+ context 'with local storage' do
+ before do
+ request!
+ end
+
+ it 'should respond with HTTP Not Found' do
+ expect(subject.status).to eq(404)
+ end
+ end
+
+ context 'with remote AWS storage', with_direct_uploads: true do
+ before do
+ request!
+ end
+
+ context 'with no filesize metadata' do
+ let(:metadata) { { fileName: 'cat.png' }.to_json }
+
+ it 'should respond with 422 due to missing file size metadata' do
+ expect(subject.status).to eq(422)
+ expect(subject.body).to include 'fileSize'
+ end
+ end
+
+ context 'with the correct parameters' do
+ let(:json) { JSON.parse subject.body }
+
+ it 'should prepare a direct upload' do
+ expect(subject.status).to eq 201
+
+ expect(json["_type"]).to eq "AttachmentUpload"
+ expect(json["fileName"]).to eq "cat.png"
+ end
+
+ describe 'response' do
+ describe '_links' do
+ describe 'container' do
+ let(:link) { json.dig "_links", "container" }
+
+ before do
+ expect(link).to be_present
+ end
+
+ it "it points to the expected container" do
+ expect(link["href"]).to eq container_href
+ end
+ end
+
+ describe 'addAttachment' do
+ let(:link) { json.dig "_links", "addAttachment" }
+
+ before do
+ expect(link).to be_present
+ end
+
+ it 'should point to AWS' do
+ expect(link["href"]).to eq "https://#{MockCarrierwave.bucket}.s3.amazonaws.com/"
+ end
+
+ it 'should have the method POST' do
+ expect(link["method"]).to eq "post"
+ end
+
+ it 'should include form fields' do
+ fields = link["form_fields"]
+
+ expect(fields).to be_present
+ expect(fields).to include(
+ "key", "acl", "policy",
+ "X-Amz-Signature", "X-Amz-Credential", "X-Amz-Algorithm", "X-Amz-Date",
+ "success_action_status"
+ )
+
+ expect(fields["key"]).to end_with "cat.png"
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
+
shared_examples 'an APIv3 attachment resource', type: :request, content_type: :json do |include_by_container = true|
include Rack::Test::Methods
include API::V3::Utilities::PathHelper
@@ -366,6 +472,11 @@ shared_examples 'an APIv3 attachment resource', type: :request, content_type: :j
end
context 'by container', if: include_by_container do
+ it_behaves_like 'it supports direct uploads' do
+ let(:request_path) { "/api/v3/#{attachment_type}s/#{container.id}/attachments/prepare" }
+ let(:container_href) { "/api/v3/#{attachment_type}s/#{container.id}" }
+ end
+
subject(:response) { last_response }
describe '#get' do
diff --git a/spec/requests/api/v3/attachments_spec.rb b/spec/requests/api/v3/attachments_spec.rb
new file mode 100644
index 0000000000..3940f03423
--- /dev/null
+++ b/spec/requests/api/v3/attachments_spec.rb
@@ -0,0 +1,103 @@
+#-- 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 'spec_helper'
+require_relative 'attachments/attachment_resource_shared_examples'
+
+describe API::V3::Attachments::AttachmentsAPI, type: :request do
+ include Rack::Test::Methods
+ include API::V3::Utilities::PathHelper
+ include FileHelpers
+
+ let(:current_user) { FactoryBot.create(:user, member_in_project: project, member_through_role: role) }
+
+ let(:project) { FactoryBot.create(:project, public: false) }
+ let(:role) { FactoryBot.create(:role, permissions: permissions) }
+ let(:permissions) { [:add_work_packages] }
+
+ context(
+ 'with missing permissions',
+ with_config: {
+ attachments_storage: :fog,
+ fog: { credentials: { provider: 'AWS' } }
+ }
+ ) do
+ let(:permissions) { [] }
+
+ let(:request_path) { api_v3_paths.prepare_new_attachment_upload }
+ let(:request_parts) { { metadata: metadata, file: file } }
+ let(:metadata) { { fileName: 'cat.png' }.to_json }
+ let(:file) { mock_uploaded_file(name: 'original-filename.txt') }
+
+ before do
+ post request_path, request_parts
+ end
+
+ it 'should forbid to prepare attachments' do
+ expect(last_response.status).to eq 403
+ end
+ end
+
+ it_behaves_like 'it supports direct uploads' do
+ let(:request_path) { api_v3_paths.prepare_new_attachment_upload }
+ let(:container_href) { nil }
+
+ describe 'GET /uploaded' do
+ let(:digest) { "" }
+ let(:attachment) { FactoryBot.create :attachment, digest: digest, author: current_user, container: nil, container_type: nil, downloads: -1 }
+
+ before do
+ get "/api/v3/attachments/#{attachment.id}/uploaded"
+ end
+
+ context 'with no pending attachments' do
+ let(:digest) { "0xFF" }
+
+ it 'should return 404' do
+ expect(last_response.status).to eq 404
+ end
+ end
+
+ context 'with a pending attachment' do
+ it 'should enqueue a FinishDirectUpload job' do
+ expect(::Attachments::FinishDirectUploadJob).to have_been_enqueued.at_least(1)
+ end
+
+ it 'should respond with HTTP OK' do
+ expect(last_response.status).to eq 200
+ end
+
+ it 'should return the attachment representation' do
+ json = JSON.parse last_response.body
+
+ expect(json["_type"]).to eq "Attachment"
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/carrierwave.rb b/spec/support/carrierwave.rb
index 72de19221d..bf7a280eea 100644
--- a/spec/support/carrierwave.rb
+++ b/spec/support/carrierwave.rb
@@ -26,17 +26,31 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
-mock_credentials = {
- provider: 'AWS',
- aws_access_key_id: 'someaccesskeyid',
- aws_secret_access_key: 'someprivateaccesskey',
- region: 'us-east-1'
-}
-mock_bucket = 'test-bucket'
+module MockCarrierwave
+ extend self
-Fog.mock!
-Fog.credentials = mock_credentials
-CarrierWave::Configuration.configure_fog! directory: mock_bucket, credentials: mock_credentials
+ def apply
+ Fog.mock!
+ Fog.credentials = credentials
-connection = Fog::Storage.new provider: mock_credentials[:provider]
-connection.directories.create key: mock_bucket
+ CarrierWave::Configuration.configure_fog! directory: bucket, credentials: credentials
+
+ connection = Fog::Storage.new provider: credentials[:provider]
+ connection.directories.create key: bucket
+ end
+
+ def bucket
+ 'test-bucket'
+ end
+
+ def credentials
+ {
+ provider: 'AWS',
+ aws_access_key_id: 'someaccesskeyid',
+ aws_secret_access_key: 'someprivateaccesskey',
+ region: 'us-east-1'
+ }
+ end
+end
+
+MockCarrierwave.apply
diff --git a/spec/support/shared/with_config.rb b/spec/support/shared/with_config.rb
index fae1294c8e..f96a036abe 100644
--- a/spec/support/shared/with_config.rb
+++ b/spec/support/shared/with_config.rb
@@ -27,36 +27,64 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
-def aggregate_mocked_configuration(example, config)
- # We have to manually check parent groups for with_config:,
- # since they are being ignored otherwise
- example.example_group.module_parents.each do |parent|
- if parent.respond_to?(:metadata) && parent.metadata[:with_config]
- config.reverse_merge!(parent.metadata[:with_config])
+class WithConfig
+ attr_reader :context
+
+ def initialize(context)
+ @context = context
+ end
+
+ ##
+ # We need this so calls to rspec mocks (allow, expect etc.) will work here as expected.
+ def method_missing(method, *args, &block)
+ if context.respond_to?(method)
+ context.send method, *args, &block
+ else
+ super
end
end
- config
+ ##
+ # Stubs the given configurations.
+ #
+ # @config [Hash] Hash containing the configurations with keys as seen in `configuration.rb`.
+ def before(example, config)
+ allow(OpenProject::Configuration).to receive(:[]).and_call_original
+
+ aggregate_mocked_configuration(example, config)
+ .with_indifferent_access
+ .each(&method(:stub_key))
+ end
+
+ def stub_key(key, value)
+ allow(OpenProject::Configuration)
+ .to receive(:[])
+ .with(key.to_s)
+ .and_return(value)
+
+ allow(OpenProject::Configuration)
+ .to receive(:[])
+ .with(key.to_sym)
+ .and_return(value)
+ end
+
+ def aggregate_mocked_configuration(example, config)
+ # We have to manually check parent groups for with_config:,
+ # since they are being ignored otherwise
+ example.example_group.module_parents.each do |parent|
+ if parent.respond_to?(:metadata) && parent.metadata[:with_config]
+ config.reverse_merge!(parent.metadata[:with_config])
+ end
+ end
+
+ config
+ end
end
RSpec.configure do |config|
config.before(:each) do |example|
- config = example.metadata[:with_config]
- if config.present?
- config = aggregate_mocked_configuration(example, config).with_indifferent_access
-
- allow(OpenProject::Configuration).to receive(:[]).and_call_original
- config.each do |k, v|
- allow(OpenProject::Configuration)
- .to receive(:[])
- .with(k.to_s)
- .and_return(v)
-
- allow(OpenProject::Configuration)
- .to receive(:[])
- .with(k.to_sym)
- .and_return(v)
- end
- end
+ with_config = example.metadata[:with_config]
+
+ WithConfig.new(self).before example, with_config if with_config.present?
end
end
diff --git a/spec/support/shared/with_direct_uploads.rb b/spec/support/shared/with_direct_uploads.rb
new file mode 100644
index 0000000000..c0ebd68325
--- /dev/null
+++ b/spec/support/shared/with_direct_uploads.rb
@@ -0,0 +1,199 @@
+#-- 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 WithDirectUploads
+ attr_reader :context
+
+ def initialize(context)
+ @context = context
+ end
+
+ ##
+ # We need this so calls to rspec mocks (allow, expect etc.) will work here as expected.
+ def method_missing(method, *args, &block)
+ if context.respond_to?(method)
+ context.send method, *args, &block
+ else
+ super
+ end
+ end
+
+ def before(example)
+ stub_config example
+
+ mock_attachment
+ stub_frontend redirect: redirect?(example) if stub_frontend?(example)
+
+ stub_uploader
+ end
+
+ def stub_frontend?(example)
+ example.metadata[:js]
+ end
+
+ def redirect?(example)
+ example.metadata[:with_direct_uploads] == :redirect
+ end
+
+ def around(example)
+ example.metadata[:driver] = :headless_firefox_billy
+
+ csp_config = SecureHeaders::Configuration.instance_variable_get("@default_config").csp
+ csp_config.connect_src = ["'self'", "my-bucket.s3.amazonaws.com"]
+
+ begin
+ example.run
+ ensure
+ csp_config.connect_src = %w('self')
+ end
+ end
+
+ def mock_attachment
+ allow(Attachment).to receive(:create) do |*args|
+ # We don't use create here because this would cause an infinite loop as FogAttachment's #create
+ # uses the base class's #create which is what we are mocking here. All this is necessary to begin
+ # with because the Attachment class is initialized with the LocalFileUploader before this test
+ # is ever run and we need remote attachments using the FogFileUploader in this scenario.
+ record = FogAttachment.new *args
+ record.save
+ record
+ end
+
+ # This is so the uploaded callback works. Since we can't actually substitute the Attachment class
+ # used there we get a LocalFileUploader file for the attachment which is not readable when
+ # everything else is mocked to be remote.
+ allow_any_instance_of(FileUploader).to receive(:readable?).and_return true
+ end
+
+ def stub_frontend(redirect: false)
+ proxy.stub("https://" + OpenProject::Configuration.remote_storage_host + ":443/", method: 'options').and_return(
+ headers: {
+ 'Access-Control-Allow-Methods' => 'POST',
+ 'Access-Control-Allow-Origin' => '*'
+ },
+ code: 200
+ )
+
+ if redirect
+ stub_with_redirect
+ else # use status response instead of redirect by default
+ stub_with_status
+ end
+ end
+
+ def stub_with_redirect
+ proxy
+ .stub("https://" + OpenProject::Configuration.remote_storage_host + ":443/", method: 'post')
+ .and_return(Proc.new { |params, headers, body, url, method|
+ key = body.scan(/key"\s*([^\s]+)\s/m).flatten.first
+ redirect_url = body.scan(/success_action_redirect"\s*(http[^\s]+)\s/m).flatten.first
+ ok = body =~ /X-Amz-Signature/ # check that the expected post to AWS was made with the form fields
+
+ {
+ code: ok ? 302 : 403,
+ headers: {
+ 'Location' => ok ? redirect_url + '?key=' + CGI.escape(key) : nil,
+ 'Access-Control-Allow-Methods' => 'POST',
+ 'Access-Control-Allow-Origin' => '*'
+ }
+ }
+ })
+ end
+
+ def stub_with_status
+ proxy
+ .stub("https://" + OpenProject::Configuration.remote_storage_host + ":443/", method: 'post')
+ .and_return(Proc.new { |params, headers, body, url, method|
+ {
+ code: (body =~ /X-Amz-Signature/) ? 201 : 403, # check that the expected post to AWS was made with the form fields
+ headers: {
+ 'Access-Control-Allow-Methods' => 'POST',
+ 'Access-Control-Allow-Origin' => '*'
+ }
+ }
+ })
+ end
+
+ def stub_uploader
+ creds = config[:fog][:credentials]
+
+ allow_any_instance_of(FogFileUploader).to receive(:fog_credentials).and_return creds
+
+ allow_any_instance_of(FogFileUploader).to receive(:aws_access_key_id).and_return creds[:aws_access_key_id]
+ allow_any_instance_of(FogFileUploader).to receive(:aws_secret_access_key).and_return creds[:aws_secret_access_key]
+ allow_any_instance_of(FogFileUploader).to receive(:provider).and_return creds[:provider]
+ allow_any_instance_of(FogFileUploader).to receive(:region).and_return creds[:region]
+ allow_any_instance_of(FogFileUploader).to receive(:directory).and_return config[:fog][:directory]
+
+ allow(OpenProject::Configuration).to receive(:direct_uploads?).and_return(true)
+ end
+
+ def stub_config(example)
+ WithConfig.new(context).before example, config
+ end
+
+ def config
+ {
+ attachments_storage: :fog,
+ fog: {
+ directory: MockCarrierwave.bucket,
+ credentials: MockCarrierwave.credentials
+ }
+ }
+ end
+end
+
+RSpec.configure do |config|
+ config.before(:each) do |example|
+ next unless example.metadata[:with_direct_uploads]
+
+ WithDirectUploads.new(self).before example
+
+ class FogAttachment < Attachment
+ # Remounting the uploader overrides the original file setter taking care of setting,
+ # among other things, the content type. So we have to restore that original
+ # method this way.
+ # We do this in a new, separate class, as to not interfere with any other specs.
+ alias_method :set_file, :file=
+ mount_uploader :file, FogFileUploader
+ alias_method :file=, :set_file
+ end
+ end
+
+ config.around(:each) do |example|
+ enabled = example.metadata[:with_direct_uploads]
+
+ if enabled
+ WithDirectUploads.new(self).around example
+ else
+ example.run
+ end
+ end
+end
diff --git a/spec/views/layouts/base.html.erb_spec.rb b/spec/views/layouts/base.html.erb_spec.rb
index 7729e17fa6..5d38cbc5c2 100644
--- a/spec/views/layouts/base.html.erb_spec.rb
+++ b/spec/views/layouts/base.html.erb_spec.rb
@@ -267,4 +267,17 @@ describe 'layouts/base', type: :view do
end
end
end
+
+ describe 'openproject_initializer meta tag' do
+ let(:current_user) { anonymous }
+ let(:base) { 'meta[name=openproject_initializer]' }
+
+ before do
+ render
+ end
+
+ it 'has the meta tag' do
+ expect(rendered).to have_selector(base, visible: false)
+ end
+ end
end
diff --git a/spec/workers/attachments/cleanup_uncontainered_job_integration_spec.rb b/spec/workers/attachments/cleanup_uncontainered_job_integration_spec.rb
index b8b242294c..4242ee269e 100644
--- a/spec/workers/attachments/cleanup_uncontainered_job_integration_spec.rb
+++ b/spec/workers/attachments/cleanup_uncontainered_job_integration_spec.rb
@@ -32,6 +32,7 @@ require 'spec_helper'
describe Attachments::CleanupUncontaineredJob, type: :job do
let(:grace_period) { 120 }
+
let!(:containered_attachment) { FactoryBot.create(:attachment) }
let!(:old_uncontainered_attachment) do
FactoryBot.create(:attachment, container: nil, created_at: Time.now - grace_period.minutes)
@@ -39,6 +40,17 @@ describe Attachments::CleanupUncontaineredJob, type: :job do
let!(:new_uncontainered_attachment) do
FactoryBot.create(:attachment, container: nil, created_at: Time.now - (grace_period - 1).minutes)
end
+
+ let!(:finished_upload) do
+ FactoryBot.create(:attachment, created_at: Time.now - grace_period.minutes, digest: "0x42")
+ end
+ let!(:old_pending_upload) do
+ FactoryBot.create(:attachment, created_at: Time.now - grace_period.minutes, digest: "", downloads: -1)
+ end
+ let!(:new_pending_upload) do
+ FactoryBot.create(:attachment, created_at: Time.now - (grace_period - 1).minutes, digest: "", downloads: -1)
+ end
+
let(:job) { described_class.new }
before do
@@ -47,10 +59,10 @@ describe Attachments::CleanupUncontaineredJob, type: :job do
.and_return(grace_period)
end
- it 'removes all uncontainered attachments that are older than the grace period' do
+ it 'removes all uncontainered attachments and pending uploads that are older than the grace period' do
job.perform
expect(Attachment.all)
- .to match_array([containered_attachment, new_uncontainered_attachment])
+ .to match_array([containered_attachment, new_uncontainered_attachment, finished_upload, new_pending_upload])
end
end