Merge remote-tracking branch 'origin/dev' into 45001-component-to-show-the-list-of-non-working-days-of-year

45001-component-to-show-the-list-of-non-working-days-of-year
Oliver Günther 2 years ago
commit 6f1953d140
No known key found for this signature in database
GPG Key ID: A3A8BDAD7C0C552C
  1. 2
      app/models/work_package.rb
  2. 53
      app/models/work_packages/scopes/covering_dates_and_days_of_week.rb
  3. 29
      app/services/settings/update_service.rb
  4. 14
      app/services/settings/working_days_update_service.rb
  5. 43
      app/workers/work_packages/apply_working_days_change_job.rb
  6. 14
      config/constants/settings/definition.rb
  7. 5
      config/constants/settings/definitions.rb
  8. 3
      config/locales/crowdin/af.yml
  9. 3
      config/locales/crowdin/ar.yml
  10. 3
      config/locales/crowdin/az.yml
  11. 3
      config/locales/crowdin/be.yml
  12. 3
      config/locales/crowdin/bg.yml
  13. 3
      config/locales/crowdin/ca.yml
  14. 3
      config/locales/crowdin/ckb-IR.yml
  15. 3
      config/locales/crowdin/cs.yml
  16. 3
      config/locales/crowdin/da.yml
  17. 3
      config/locales/crowdin/de.yml
  18. 3
      config/locales/crowdin/el.yml
  19. 3
      config/locales/crowdin/eo.yml
  20. 3
      config/locales/crowdin/es.yml
  21. 3
      config/locales/crowdin/et.yml
  22. 3
      config/locales/crowdin/eu.yml
  23. 3
      config/locales/crowdin/fa.yml
  24. 3
      config/locales/crowdin/fi.yml
  25. 3
      config/locales/crowdin/fil.yml
  26. 3
      config/locales/crowdin/fr.yml
  27. 3
      config/locales/crowdin/he.yml
  28. 3
      config/locales/crowdin/hi.yml
  29. 3
      config/locales/crowdin/hr.yml
  30. 3
      config/locales/crowdin/hu.yml
  31. 3
      config/locales/crowdin/id.yml
  32. 3
      config/locales/crowdin/it.yml
  33. 3
      config/locales/crowdin/ja.yml
  34. 3
      config/locales/crowdin/ko.yml
  35. 3
      config/locales/crowdin/lol.yml
  36. 3
      config/locales/crowdin/lt.yml
  37. 3
      config/locales/crowdin/lv.yml
  38. 3
      config/locales/crowdin/ne.yml
  39. 3
      config/locales/crowdin/nl.yml
  40. 3
      config/locales/crowdin/no.yml
  41. 3
      config/locales/crowdin/pl.yml
  42. 3
      config/locales/crowdin/pt.yml
  43. 3
      config/locales/crowdin/ro.yml
  44. 3
      config/locales/crowdin/ru.yml
  45. 3
      config/locales/crowdin/rw.yml
  46. 3
      config/locales/crowdin/si.yml
  47. 3
      config/locales/crowdin/sk.yml
  48. 3
      config/locales/crowdin/sl.yml
  49. 7
      config/locales/crowdin/sv.yml
  50. 3
      config/locales/crowdin/th.yml
  51. 3
      config/locales/crowdin/tr.yml
  52. 3
      config/locales/crowdin/uk.yml
  53. 3
      config/locales/crowdin/vi.yml
  54. 3
      config/locales/crowdin/zh-TW.yml
  55. 3
      config/locales/en.yml
  56. 65
      docs/use-cases/portfolio-management/README.md
  57. BIN
      docs/use-cases/portfolio-management/Wiki.png
  58. BIN
      docs/use-cases/portfolio-management/chose-project.jpg
  59. BIN
      docs/use-cases/portfolio-management/custom-reports.jpg
  60. BIN
      docs/use-cases/portfolio-management/dynamic-data.jpg
  61. BIN
      docs/use-cases/portfolio-management/export_options.png
  62. BIN
      docs/use-cases/portfolio-management/gantt_view.png
  63. BIN
      docs/use-cases/portfolio-management/open_as_gantt_view.png
  64. BIN
      docs/use-cases/portfolio-management/openproject_export.png
  65. BIN
      docs/use-cases/portfolio-management/openproject_filter_projects.png
  66. BIN
      docs/use-cases/portfolio-management/openproject_projects_overview.png
  67. BIN
      docs/use-cases/portfolio-management/openproject_select_projects_list.png
  68. BIN
      docs/use-cases/portfolio-management/openproject_settings_for_project_overview_list.png
  69. BIN
      docs/use-cases/portfolio-management/openproject_wiki_editing.png
  70. BIN
      docs/use-cases/portfolio-management/openrpoject_configure_projects_overview.png
  71. BIN
      docs/use-cases/portfolio-management/sort_by_status.png
  72. 10
      docs/use-cases/resource-management/README.md
  73. BIN
      docs/use-cases/resource-management/configure_wp_view.png
  74. BIN
      docs/use-cases/resource-management/openproject_display_sums.png
  75. BIN
      docs/use-cases/resource-management/openproject_save_wp_adjusted_view.png
  76. BIN
      docs/use-cases/resource-management/openproject_work_packages_sum.png
  77. BIN
      docs/use-cases/resource-management/openproject_wp_gantt_view.png
  78. 18
      frontend/src/app/core/file-upload/op-direct-file-upload.service.ts
  79. 44
      frontend/src/app/core/file-upload/op-file-upload.service.ts
  80. 2
      frontend/src/app/core/state/attachments/attachments.service.ts
  81. 16
      frontend/src/app/core/state/file-links/file-links.service.ts
  82. 10
      frontend/src/app/core/state/in-app-notifications/in-app-notifications.service.ts
  83. 23
      frontend/src/app/core/state/resource-collection.service.ts
  84. 7
      frontend/src/app/features/hal/resources/error-resource.ts
  85. 14
      frontend/src/app/features/in-app-notifications/bell/state/ian-bell.service.ts
  86. 4
      frontend/src/app/shared/components/storages/file-picker-base-modal.component.ts/file-picker-base-modal.component.ts
  87. 4
      frontend/src/app/shared/components/storages/location-picker-modal/location-picker-modal.component.ts
  88. 2
      frontend/src/app/shared/components/storages/openproject-storages.module.ts
  89. 130
      frontend/src/app/shared/components/storages/services/upload-storage-files.service.ts
  90. 92
      frontend/src/app/shared/components/storages/storage/storage.component.ts
  91. 9
      frontend/src/app/shared/components/toaster/toast.component.html
  92. 90
      frontend/src/app/shared/components/toaster/upload-progress.component.ts
  93. 2
      modules/avatars/frontend/module/avatar-upload-form/avatar-upload-form.component.ts
  94. 4
      modules/grids/config/locales/crowdin/js-sv.yml
  95. 150
      modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud/legacy_upload_link_query.rb
  96. 69
      modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud/upload_link_query.rb
  97. 21
      modules/storages/app/common/storages/peripherals/storage_interaction/storage_queries.rb
  98. 5
      modules/storages/app/common/storages/peripherals/storage_interaction/upload_link_query_helpers.rb
  99. 4
      modules/storages/app/common/storages/peripherals/storage_requests.rb
  100. 6
      modules/storages/app/models/storages/upload_link.rb
  101. Some files were not shown because too many files have changed in this diff Show More

@ -120,7 +120,7 @@ class WorkPackage < ApplicationRecord
where(author_id: author.id)
}
scopes :covering_days_of_week,
scopes :covering_dates_and_days_of_week,
:for_scheduling,
:include_derived_dates,
:include_spent_time,

@ -26,27 +26,29 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module WorkPackages::Scopes::CoveringDaysOfWeek
module WorkPackages::Scopes::CoveringDatesAndDaysOfWeek
extend ActiveSupport::Concern
using CoreExtensions::SquishSql
class_methods do
# Fetches all work packages that cover specific days of the week.
# Fetches all work packages that cover specific days of the week, and/or specific dates.
#
# The period considered is from the work package start date to the due date.
#
# @param dates Date[] An array of the Date objects.
# @param days_of_week number[] An array of the ISO days of the week to
# consider. 1 is Monday, 7 is Sunday.
def covering_days_of_week(days_of_week)
def covering_dates_and_days_of_week(days_of_week: [], dates: [])
days_of_week = Array(days_of_week)
return none if days_of_week.empty?
dates = Array(dates)
return none if days_of_week.empty? && dates.empty?
where("id IN (#{query(days_of_week)})")
where("id IN (#{query(days_of_week, dates)})")
end
private
def query(days_of_week)
def query(days_of_week, dates)
sql = <<~SQL.squish
-- select work packages dates with their followers dates
WITH work_packages_with_dates AS (
@ -59,7 +61,6 @@ module WorkPackages::Scopes::CoveringDaysOfWeek
work_packages.start_date IS NOT NULL
OR work_packages.due_date IS NOT NULL
)
ORDER BY work_packages.id
),
-- coalesce non-existing dates of work package to get period start/end
work_packages_periods AS (
@ -68,29 +69,29 @@ module WorkPackages::Scopes::CoveringDaysOfWeek
GREATEST(work_package_start_date, work_package_due_date) AS end_date
FROM work_packages_with_dates
),
-- expand period into days of the week. Limit to 7 days (more would be useless).
work_packages_days_of_week AS (
SELECT id,
extract(
isodow
from generate_series(
work_packages_periods.start_date,
LEAST(
work_packages_periods.start_date + 6,
work_packages_periods.end_date
),
'1 day'
)
) AS dow
FROM work_packages_periods
-- All days between the start date of a work package and its due date
covered_dates AS (
SELECT
id,
generate_series(work_packages_periods.start_date,
work_packages_periods.end_date,
'1 day') AS date
FROM work_packages_periods
),
-- All days between the start date of a work package and its due date including the day of the week for each date
covered_dates_and_wday AS (
SELECT
id,
date,
EXTRACT(isodow FROM date) dow
FROM covered_dates
)
-- select id of work packages covering the given days
SELECT DISTINCT id
FROM work_packages_days_of_week
WHERE dow IN (:days_of_week)
SELECT id FROM covered_dates_and_wday
WHERE dow IN (:days_of_week) OR date IN (:dates)
SQL
sanitize_sql([sql, { days_of_week: }])
sanitize_sql([sql, { days_of_week:, dates: }])
end
end
end

@ -32,13 +32,6 @@ class Settings::UpdateService < BaseServices::BaseContracted
contract_class: Settings::UpdateContract
end
def after_validate(params, call)
params.keys.each(&method(:remember_previous_value))
call
end
# We will have a problem with error handling on the form.
# How can we still display the user changed values in case the form is not successfully saved?
def persist(call)
params.each do |name, value|
set_setting_value(name, value)
@ -46,34 +39,12 @@ class Settings::UpdateService < BaseServices::BaseContracted
call
end
def after_perform(call)
super.tap do
params.each_key do |name|
run_on_change_callback(name)
end
end
end
private
def remember_previous_value(name)
previous_values[name] = Setting[name]
end
def set_setting_value(name, value)
Setting[name] = derive_value(value)
end
def previous_values
@previous_values ||= {}
end
def run_on_change_callback(name)
if (definition = Settings::Definition[name]) && definition.on_change
definition.on_change.call(previous_values[name])
end
end
def derive_value(value)
case value
when Array, Hash

@ -30,6 +30,8 @@ class Settings::WorkingDaysUpdateService < Settings::UpdateService
def call(params)
params = params.to_h.deep_symbolize_keys
self.non_working_days_params = params.delete(:non_working_days) || []
self.previous_working_days = Setting[:working_days]
self.previous_non_working_days = NonWorkingDay.pluck(:date)
super
end
@ -54,9 +56,19 @@ class Settings::WorkingDaysUpdateService < Settings::UpdateService
results
end
def after_perform(call)
super.tap do
WorkPackages::ApplyWorkingDaysChangeJob.perform_later(
user_id: User.current.id,
previous_working_days:,
previous_non_working_days:
)
end
end
private
attr_accessor :non_working_days_params
attr_accessor :non_working_days_params, :previous_working_days, :previous_non_working_days
def persist_non_working_days
# We don't support update for now

@ -30,18 +30,19 @@ class WorkPackages::ApplyWorkingDaysChangeJob < ApplicationJob
queue_with_priority :above_normal
include ::ScheduledJob
def perform(user_id:, previous_working_days:)
def perform(user_id:, previous_working_days:, previous_non_working_days:)
user = User.find(user_id)
User.execute_as user do
updated_work_package_ids = collect_id_for_each(applicable_work_package(previous_working_days)) do |work_package|
updated_work_package_ids = collect_id_for_each(applicable_work_package(previous_working_days,
previous_non_working_days)) do |work_package|
apply_change_to_work_package(user, work_package)
end
updated_work_package_ids += collect_id_for_each(applicable_predecessor(updated_work_package_ids)) do |predecessor|
apply_change_to_predecessor(user, predecessor)
end
set_journal_notice(updated_work_package_ids, previous_working_days)
set_journal_notice(updated_work_package_ids, previous_working_days, previous_non_working_days)
end
end
@ -68,11 +69,11 @@ class WorkPackages::ApplyWorkingDaysChangeJob < ApplicationJob
end
end
def applicable_work_package(previous_working_days)
changed_days = changed_days(previous_working_days)
def applicable_work_package(previous_working_days, previous_non_working_days)
days_of_week = changed_days(previous_working_days).keys
dates = changed_non_working_dates(previous_non_working_days).keys
WorkPackage
.covering_days_of_week(changed_days)
.covering_dates_and_days_of_week(days_of_week:, dates:)
.order(WorkPackage.arel_table[:start_date].asc.nulls_first,
WorkPackage.arel_table[:due_date].asc)
end
@ -83,7 +84,16 @@ class WorkPackages::ApplyWorkingDaysChangeJob < ApplicationJob
# `^` is a Set method returning a new set containing elements exclusive to
# each other
(previous ^ current).to_a
(previous ^ current).index_with { |day| current.include?(day) }
end
def changed_non_working_dates(previous_non_working_days)
previous = Set.new(previous_non_working_days)
current = Set.new(NonWorkingDay.pluck(:date))
# `^` is a Set method returning a new set containing elements exclusive to
# each other
(previous ^ current).index_with { |day| current.exclude?(day) }
end
def applicable_predecessor(excluded)
@ -92,9 +102,10 @@ class WorkPackages::ApplyWorkingDaysChangeJob < ApplicationJob
.where.not(id: excluded)
end
def set_journal_notice(updated_work_package_ids, previous_working_days)
day_changes = changed_days(previous_working_days).index_with { |day| Setting.working_days.include?(day) }
journal_note = journal_notice_text(day_changes)
def set_journal_notice(updated_work_package_ids, previous_working_days, previous_non_working_days)
day_changes = changed_days(previous_working_days)
date_changes = changed_non_working_dates(previous_non_working_days)
journal_note = journal_notice_text(day_changes, date_changes)
WorkPackage
.where(id: updated_work_package_ids.uniq)
@ -105,10 +116,12 @@ class WorkPackages::ApplyWorkingDaysChangeJob < ApplicationJob
end
end
def journal_notice_text(day_changes)
def journal_notice_text(day_changes, date_changes)
I18n.with_locale(Setting.default_language) do
day_changes_messages = day_changes.collect { |day, working| working_day_change_message(day, working) }
date_changes_messages = date_changes.collect { |date, working| working_date_change_message(date, working) }
I18n.t(:'working_days.journal_note.changed',
changes: day_changes.collect { |day, working| working_day_change_message(day, working) }.join(', '))
changes: (day_changes_messages + date_changes_messages).join(', '))
end
end
@ -117,6 +130,10 @@ class WorkPackages::ApplyWorkingDaysChangeJob < ApplicationJob
day: WeekDay.find_by!(day:).name)
end
def working_date_change_message(date, working)
I18n.t(:"working_days.journal_note.dates.#{working ? :working : :non_working}", date:)
end
def collect_id_for_each(scope)
scope.pluck(:id).map do |id|
yield(WorkPackage.find(id)).pluck(:id)

@ -33,8 +33,7 @@ module Settings
attr_accessor :name,
:format,
:env_alias,
:on_change
:env_alias
attr_writer :value,
:allowed
@ -44,8 +43,7 @@ module Settings
format: nil,
writable: true,
allowed: nil,
env_alias: nil,
on_change: nil)
env_alias: nil)
self.name = name.to_s
@default = default.is_a?(Hash) ? default.deep_stringify_keys : default
@default.freeze
@ -54,7 +52,6 @@ module Settings
self.writable = writable
self.allowed = allowed
self.env_alias = env_alias
self.on_change = on_change
end
def default
@ -136,14 +133,12 @@ module Settings
# @param [nil] env_alias Alternative for the default env name to also look up. E.g. with the alias set to
# `OPENPROJECT_2FA` for a definition with the name `two_factor_authentication`, the value is fetched
# from the ENV OPENPROJECT_2FA as well.
# @param [nil] on_change A callback lambda to be triggered whenever the setting is stored to the database.
def add(name,
default:,
format: nil,
writable: true,
allowed: nil,
env_alias: nil,
on_change: nil)
env_alias: nil)
return if @by_name.present? && @by_name[name.to_s].present?
@by_name = nil
@ -153,8 +148,7 @@ module Settings
default:,
writable:,
allowed:,
env_alias:,
on_change:)
env_alias:)
override_value(definition)

@ -938,10 +938,7 @@ Settings::Definition.define do
add :working_days,
format: :array,
allowed: Array(1..7),
default: Array(1..5), # Sat, Sun being non-working days
on_change: ->(previous_working_days) do
WorkPackages::ApplyWorkingDaysChangeJob.perform_later(user_id: User.current.id, previous_working_days:)
end
default: Array(1..5) # Sat, Sun being non-working days
add :youtube_channel,
default: 'https://www.youtube.com/c/OpenProjectCommunity',

@ -2966,6 +2966,9 @@ af:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Niks om te voorskou"
api_v3:
attributes:

@ -3043,6 +3043,9 @@ ar:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "لا شيء للمعاينة"
api_v3:
attributes:

@ -2966,6 +2966,9 @@ az:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nothing to preview"
api_v3:
attributes:

@ -3008,6 +3008,9 @@ be:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nothing to preview"
api_v3:
attributes:

@ -2966,6 +2966,9 @@ bg:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nothing to preview"
api_v3:
attributes:

@ -2955,6 +2955,9 @@ ca:
days:
working: "%{day} és ara laboral"
non_working: "%{day} és ara no laboral"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Res per previsualitzar"
api_v3:
attributes:

@ -2966,6 +2966,9 @@ ckb-IR:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nothing to preview"
api_v3:
attributes:

@ -3006,6 +3006,9 @@ cs:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nic pro náhled"
api_v3:
attributes:

@ -2962,6 +2962,9 @@ da:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Intet at forhåndsvise"
api_v3:
attributes:

@ -2960,6 +2960,9 @@ de:
days:
working: "%{day} ist jetzt ein Arbeitstag"
non_working: "%{day} ist jetzt kein Arbeitstag"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Keine Vorschau verfügbar"
api_v3:
attributes:

@ -2960,6 +2960,9 @@ el:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Δεν υπάρχει κάτι για προεπισκόπηση"
api_v3:
attributes:

@ -2966,6 +2966,9 @@ eo:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nothing to preview"
api_v3:
attributes:

@ -2961,6 +2961,9 @@ es:
days:
working: "%{day} es ahora laboral"
non_working: "%{day} es ahora no laboral"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nada para previsualizar"
api_v3:
attributes:

@ -2966,6 +2966,9 @@ et:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Eelvaateks pole midagi näidata"
api_v3:
attributes:

@ -2966,6 +2966,9 @@ eu:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nothing to preview"
api_v3:
attributes:

@ -2966,6 +2966,9 @@ fa:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nothing to preview"
api_v3:
attributes:

@ -2966,6 +2966,9 @@ fi:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Ei esikatseltavaa"
api_v3:
attributes:

@ -2964,6 +2964,9 @@ fil:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Walang mai-preview"
api_v3:
attributes:

@ -2965,6 +2965,9 @@ fr:
days:
working: "%{day} est maintenant un jour ouvrable"
non_working: "%{day} est maintenant un jour non ouvrable"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Rien à afficher en apperçu"
api_v3:
attributes:

@ -3008,6 +3008,9 @@ he:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nothing to preview"
api_v3:
attributes:

@ -2964,6 +2964,9 @@ hi:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nothing to preview"
api_v3:
attributes:

@ -2987,6 +2987,9 @@ hr:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Ne postoji zapis za pregled"
api_v3:
attributes:

@ -2962,6 +2962,9 @@ hu:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nincs előnézet"
api_v3:
attributes:

@ -2937,6 +2937,9 @@ id:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Tidak ada pre-view"
api_v3:
attributes:

@ -2963,6 +2963,9 @@ it:
days:
working: "%{day} ora è lavorativo"
non_working: "%{day} ora non è lavorativo"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Niente in anteprima"
api_v3:
attributes:

@ -2940,6 +2940,9 @@ ja:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "プレビューできるものは何もありません。"
api_v3:
attributes:

@ -2941,6 +2941,9 @@ ko:
days:
working: "%{day}은(는) 이제 근무일입니다."
non_working: "%{day}은(는) 이제 휴무일입니다."
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "미리 볼 내용 없음"
api_v3:
attributes:

@ -2934,6 +2934,9 @@ lol:
days:
working: "crwdns834174:0%{day}crwdne834174:0"
non_working: "crwdns834176:0%{day}crwdne834176:0"
dates:
working: "crwdns844503:0%{date}crwdne844503:0"
non_working: "crwdns844505:0%{date}crwdne844505:0"
nothing_to_preview: "crwdns499773:0crwdne499773:0"
api_v3:
attributes:

@ -3002,6 +3002,9 @@ lt:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nėra ką rodyti"
api_v3:
attributes:

@ -2982,6 +2982,9 @@ lv:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nothing to preview"
api_v3:
attributes:

@ -2966,6 +2966,9 @@ ne:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nothing to preview"
api_v3:
attributes:

@ -2961,6 +2961,9 @@ nl:
days:
working: "%{day} werkt nu"
non_working: "%{day} is nu niet actief"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Niets om te bekijken"
api_v3:
attributes:

@ -2966,6 +2966,9 @@
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Ingenting å forhåndsvise"
api_v3:
attributes:

@ -3002,6 +3002,9 @@ pl:
days:
working: "%{day} jest teraz dniem roboczym"
non_working: "%{day} jest teraz dniem nieroboczym"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nie ma nic do podglądu"
api_v3:
attributes:

@ -2962,6 +2962,9 @@ pt:
days:
working: "%{day} agora é um dia útil"
non_working: "%{day} agora é um dia não-útil"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nada para visualizar"
api_v3:
attributes:

@ -2986,6 +2986,9 @@ ro:
days:
working: "%{day} funcționează acum"
non_working: "%{day} este acum nefuncțională"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nimic de previzualizat"
api_v3:
attributes:

@ -3003,6 +3003,9 @@ ru:
days:
working: "%{day} сейчас работает"
non_working: "%{day} теперь не работает"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Нет ничего для предосмотра"
api_v3:
attributes:

@ -2966,6 +2966,9 @@ rw:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nothing to preview"
api_v3:
attributes:

@ -2966,6 +2966,9 @@ si:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "පරදසමට කවකත"
api_v3:
attributes:

@ -3007,6 +3007,9 @@ sk:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nič na zobrazenie náhľadu"
api_v3:
attributes:

@ -3004,6 +3004,9 @@ sl:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Ničesar za predogled"
api_v3:
attributes:

@ -483,9 +483,9 @@ sv:
explanation: 'Statusbeskrivning'
codes:
not_started: 'Not started'
on_track: 'På banan'
on_track: 'På rätt spår'
at_risk: 'I riskzonen'
off_track: 'Utanför banan'
off_track: 'Urspårat'
finished: 'Finished'
discontinued: 'Discontinued'
query:
@ -2962,6 +2962,9 @@ sv:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Ingenting att förhandsgranska"
api_v3:
attributes:

@ -2945,6 +2945,9 @@ th:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "ไมอะไรใหแสดงเปนตวอยาง"
api_v3:
attributes:

@ -2965,6 +2965,9 @@ tr:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Önizlenecek bir şey yok"
api_v3:
attributes:

@ -2999,6 +2999,9 @@ uk:
days:
working: "%{day} зараз робочий"
non_working: "%{day} зараз неробочий"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Нічого для перегляду"
api_v3:
attributes:

@ -2946,6 +2946,9 @@ vi:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nothing to preview"
api_v3:
attributes:

@ -2944,6 +2944,9 @@ zh-TW:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "沒有內容可預覽"
api_v3:
attributes:

@ -3160,6 +3160,9 @@ en:
days:
working: "%{day} is now working"
non_working: "%{day} is now non-working"
dates:
working: "%{date} is now working"
non_working: "%{date} is now non-working"
nothing_to_preview: "Nothing to preview"
api_v3:

@ -8,30 +8,73 @@ keywords: use-case, portfolio management
# Use Case: Portfolio Management and Custom Reporting Options
Step 1: To view all projects, first select the *project chooser drop-down*, the chose *View all projects*
If you have a lot of projects running at the same time it can be helpful and even necessary to have a meta level overview of your projects, keep track of the project status and due dates. With OpenProject you can do just that.
![Chose project](chose-project.jpg)
![OpenProject projects portfolio overview](openproject_projects_overview.png)
## Creating projects overview
**Step 1:** To view all projects, first select the **Select a project** dropdown menu, then click on the **Projects list** button.
![open project list](openproject_select_projects_list.png)
**Step 2:** You will get a list of all the projects that exist in your organization. You can filter the list by various project attributes, such as **project owner or creation date**. You can also use project custom fields as filters (please keep in mind that this is an enterprise add-on). If you have not added any custom fields yet, please see [here](../../system-admin-guide/custom-fields/) how to do it.
![OpenProject filter projects view](openproject_filter_projects.png)
You can then sort the project list by clicking on a column heading, for example by project status.
![Openproject sort project list by status](sort_by_status.png)
You can add a visual component to the overview by clicking on the **Open as Gantt view** button.
![OpenProject projects Gantt overview](open_as_gantt_view.png)
![OpenProject projects in Gantt view](gantt_view.png)
**Step 3:** You can also configure this view using the button with the three dots at the upper right corner and select **Configure**.
![OpenProject configure projects overview](openrpoject_configure_projects_overview.png)
You will then be led to the **System settings** of the global Administration. If you scroll down the page, you can select which columns are to be displayed in the project list in the section **Settings for project overview list** (you will need to scroll down the page). Please save your changes via the blue **Save** button, at the bottom of the page.
![OpenProject settings for project overview list](openproject_settings_for_project_overview_list.png)
If you click on **Edit query** you can adjust the project overview when using the Gantt chart option.
Step 2: You can sort and filter this view using custom fields, such as *Initiative* or *Status.* If you have not added useful custom fields, please see [here](../../system-admin-guide/custom-fields/).
Step 3: Press the **floppy disk icon** to save and name your view once you are happy with the information that is displayed. You can also add this view as a favorite to the black bar on the left.
## Creating Custom Reports
You can create advanced project reports by using the same techniques and the print function (**CTRL+P**), then saving as PDF, for example. The print function in OpenProject is optimized for reporting purposes. Only information displayed in the main screen area is included. None of the designs or side or top menus are in it.
### Exporting reports
For creating custom project reports you can use the export function.
![Openproject reports export](openproject_export.png)
You can export the work packages in one of the following formats.
![Openproject export options](export_options.png)
Another option would be to use the print function (**CTRL+P**) and then saving as PDF. The print function in OpenProject is optimized for reporting purposes. Only information displayed in the main screen area is included. None of the designs or side or top menus are in it. Please see here [how to print a Gantt chart](../../user-guide/gantt-chart/#how-to-print-a-gantt-chart).
### Project status reporting
You can [display and configure the individual project status](../../user-guide/projects/project-status/) on the project overview page.
For more advanced requirements, using the Wiki is another powerful tool. For more information about how to use the Wiki function, please consult: [Wiki](../../user-guide/wiki/)
For more advanced project reporting requirements, using the Wiki module is another powerful tool. For more information about how to use the Wiki function, please consult the [Wiki user guide](../../user-guide/wiki/).
The Wiki function allows you to build complete custom reports using embedded work package tables, macros and even embedded calculations.
The Wiki function allows you to build complete custom reports using embedded work package tables, macros and even embedded calculations.
Here is an example of how a wiki could look:
Here is an example of how a project report wiki could look:
![Creating custom reports](custom-reports.jpg)
![Creating custom reports](Wiki.png)
And how the dynamic data, such as calculations, filters, macros and reference language work behind the scenes:
![Dynamic data](dynamic-data.jpg)
![Dynamic data](openproject_wiki_editing.png)
For more information about the syntax and how the attributes work [here](../../user-guide/wysiwyg/).
For more information about the syntax and how the attributes work please look [here](../../user-guide/wysiwyg/).
If you like to work with multiple Wiki-based reports, you can create an umbrella Wiki page as a table of content, for example, on which all the other reports are listed. See more info on Wiki and the use of Macros [here](../../user-guide/wiki/).

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

@ -8,7 +8,7 @@ keywords: use-case, resource management
# Use Case: Resource Management
**Note:** OpenProject does not have the automated functionality to provide detailed resource management or employee work capacity calculations. However, there is a workaround that you can use to configure a visual estimate of task attribution and plan accordingly.
**Note:** OpenProject does not have the automated functionality to provide detailed resource management or employee work capacity calculations. However, there is a workaround that you can use to configure a visual estimate of task distribution and plan accordingly.
These are the steps you can follow to adjust a work package overview to suit your goals.
@ -24,13 +24,13 @@ Step 2: Either use existing fields, for example **Estimated time** and **Spent t
Step 3: You can either insert the standard or existing fields to the view, or insert the custom fields if created in Step 2.
Step 4: You can add any filters necessary and and sort or group the work packages by assignee.
Step 4: You can add any filters necessary and sort or group the work packages by assignee.
![OpenProject sort work packages by assignee](openproject_sort_by_assignee.png)
Step 5: Save your adjusted view by clicking on the **Save** icon on the left (you can name this view before saving or re-name it later). ![Save adjusted openrpoject workpage view](openproject_save_wp_adjusted_view.png)
This view will be saved and shown under your private work package filters.
This view will be saved and shown under your private work package filters (you can make it public and share with other team members).
![OpenProjec work package private filter](work_package_private_filter.png)
@ -48,4 +48,8 @@ You can also use the sum function. Select **[⋮]** -> ***Configure view*** -> *
![OpenProject display sums](openproject_display_sums.png)
You will see the estimated, spent and remaining hours summed up by user, as well as the overall sum.
![OpenProject work packages sums](openproject_work_packages_sum.png)
**Limitations:** While this workaround provides a visual estimate of who works on what and when, it does not replace a dedicated capacity management tool.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

After

Width:  |  Height:  |  Size: 241 KiB

@ -26,16 +26,17 @@
// See COPYRIGHT and LICENSE files for more details.
//++
import { getType } from 'mime';
import { Injectable } from '@angular/core';
import { HttpEvent, HttpResponse } from '@angular/common/http';
import { from, Observable, of } from 'rxjs';
import { share, switchMap } from 'rxjs/operators';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { getType } from 'mime';
import { EXTERNAL_REQUEST_HEADER } from 'core-app/features/hal/http/openproject-header-interceptor';
import {
OpenProjectFileUploadService, UploadBlob, UploadFile, UploadInProgress,
} from './op-file-upload.service';
import { EXTERNAL_REQUEST_HEADER } from "core-app/features/hal/http/openproject-header-interceptor";
interface PrepareUploadResult {
url:string;
@ -53,25 +54,22 @@ interface PrepareUploadResult {
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 = 'post', responseType:'text'|'json' = 'text') {
public uploadSingle(url:string, file:UploadFile|UploadBlob, method = 'post') {
const observable = from(this.getDirectUploadFormFrom(url, file))
.pipe(
switchMap(this.uploadToExternal(file, method, responseType)),
switchMap(this.uploadToExternal(file, method)),
share(),
);
return [file, observable] as UploadInProgress;
}
private uploadToExternal(file:UploadFile|UploadBlob, method:string, responseType:string):(result:PrepareUploadResult) => Observable<HttpEvent<unknown>> {
private uploadToExternal(file:UploadFile|UploadBlob, method:string):(result:PrepareUploadResult) => Observable<HttpEvent<unknown>> {
return (result) => {
result.form.append('file', file, file.customName || file.name);
return this.http.request<HalResource>(
return this.http.request(
method,
result.url,
{
@ -83,7 +81,7 @@ export class OpenProjectDirectFileUploadService extends OpenProjectFileUploadSer
headers: {
[EXTERNAL_REQUEST_HEADER]: 'true',
},
responseType: responseType as 'json',
responseType: 'text',
// Subscribe to progress events. subscribe() will fire multiple times!
reportProgress: true,
},

@ -28,7 +28,10 @@
import { Injectable } from '@angular/core';
import {
HttpClient, HttpEvent, HttpEventType, HttpResponse,
HttpClient,
HttpEvent,
HttpEventType,
HttpResponse,
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map, share } from 'rxjs/operators';
@ -61,22 +64,22 @@ export interface MappedUploadResult {
@Injectable()
export class OpenProjectFileUploadService {
constructor(protected http:HttpClient,
protected halResource:HalResourceService) {
}
constructor(
protected readonly http:HttpClient,
protected readonly halResource:HalResourceService,
) { }
/**
* Upload multiple files and return a promise for each uploading file and a single promise for all processed uploads
* with their accessible URLs returned.
* @param {string} url
* @param {UploadFile[]} files
* @param {string} method
* @returns {Promise<{response:HalResource; uploadUrl:any}[]>}
*/
public uploadAndMapResponse(url:string, files:UploadFile[], method = 'post') {
public uploadAndMapResponse(url:string, files:UploadFile[]):MappedUploadResult {
const { uploads, finished } = this.upload(url, files);
const mapped = finished
.then((result:HalResource[]) => result.map((el:HalResource) => ({ response: el, uploadUrl: el.staticDownloadLocation.href }))) as Promise<{ response:HalResource, uploadUrl:string }[]>;
.then((result:HalResource[]) => result.map((element:HalResource) => ({
response: element,
uploadUrl: (element.staticDownloadLocation as unknown&{ href:string }).href,
}))) as Promise<{ response:HalResource, uploadUrl:string }[]>;
return { uploads, finished: mapped } as MappedUploadResult;
}
@ -95,11 +98,13 @@ export class 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 = 'post', responseType:'text'|'json' = 'json') {
public uploadSingle(
url:string,
file:UploadFile|UploadBlob,
method = 'post',
responseType:'json'|'text' = 'json',
):UploadInProgress {
const formData = new FormData();
const metadata = {
description: file.description,
@ -115,9 +120,7 @@ export class OpenProjectFileUploadService {
// Add the file
formData.append('file', file, metadata.fileName);
const observable = this
.http
.request<HalResource>(
const observable = this.http.request(
method,
url,
{
@ -125,14 +128,11 @@ export class OpenProjectFileUploadService {
// Observe the response, not the body
observe: 'events',
withCredentials: true,
responseType: responseType as any,
responseType,
// Subscribe to progress events. subscribe() will fire multiple times!
reportProgress: true,
},
)
.pipe(
share(),
);
).pipe(share());
return [file, observable] as UploadInProgress;
}

@ -28,7 +28,6 @@
import { Injectable } from '@angular/core';
import {
HttpClient,
HttpHeaders,
} from '@angular/common/http';
import { applyTransaction } from '@datorama/akita';
@ -52,7 +51,6 @@ import {
import { OpenProjectDirectFileUploadService } from 'core-app/core/file-upload/op-direct-file-upload.service';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
import { HalLink } from 'core-app/features/hal/hal-link/hal-link';
import isNewResource, { HAL_NEW_RESOURCE_ID } from 'core-app/features/hal/helpers/is-new-resource';
import { ConfigurationService } from 'core-app/core/config/configuration.service';

@ -38,14 +38,13 @@ import {
tap,
} from 'rxjs/operators';
import { IFileLink } from 'core-app/core/state/file-links/file-link.model';
import { IFileLink, IFileLinkOriginData } from 'core-app/core/state/file-links/file-link.model';
import { IHALCollection } from 'core-app/core/apiv3/types/hal-collection.type';
import { ToastService } from 'core-app/shared/components/toaster/toast.service';
import { FileLinksStore } from 'core-app/core/state/file-links/file-links.store';
import { insertCollectionIntoState, removeEntityFromCollectionAndState } from 'core-app/core/state/collection-store';
import { CollectionStore, ResourceCollectionService } from 'core-app/core/state/resource-collection.service';
import { IHalResourceLink } from 'core-app/core/state/hal-resource';
import { IStorageFile } from 'core-app/core/state/storage-files/storage-file.model';
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
@ -102,19 +101,10 @@ export class FileLinksResourceService extends ResourceCollectionService<IFileLin
collectionKey:string,
addFileLinksHref:string,
storage:IHalResourceLink,
filesToLink:IStorageFile[],
filesToLink:IFileLinkOriginData[],
):Observable<IHALCollection<IFileLink>> {
const elements = filesToLink.map((file) => ({
originData: {
id: file.id,
name: file.name,
mimeType: file.mimeType,
size: file.size,
createdAt: file.createdAt,
lastModifiedAt: file.lastModifiedAt,
createdByName: file.createdByName,
lastModifiedByName: file.lastModifiedByName,
},
originData: { ...file },
_links: { storage },
}));

@ -6,17 +6,11 @@ import {
markNotificationsAsRead,
notificationsMarkedRead,
} from 'core-app/core/state/in-app-notifications/in-app-notifications.actions';
import {
EffectCallback,
EffectHandler,
} from 'core-app/core/state/effects/effect-handler.decorator';
import { EffectCallback, EffectHandler } from 'core-app/core/state/effects/effect-handler.decorator';
import { ActionsService } from 'core-app/core/state/actions/actions.service';
import { InAppNotificationsStore } from './in-app-notifications.store';
import { INotification } from './in-app-notification.model';
import {
CollectionStore,
ResourceCollectionService,
} from 'core-app/core/state/resource-collection.service';
import { CollectionStore, ResourceCollectionService } from 'core-app/core/state/resource-collection.service';
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
@EffectHandler

@ -65,6 +65,10 @@ import { ToastService } from 'core-app/shared/components/toaster/toast.service';
export type CollectionStore<T> = EntityStore<CollectionState<T>>;
export interface ResourceCollectionLoadOptions {
handleErrors:boolean;
}
@Injectable()
export abstract class ResourceCollectionService<T extends { id:ID }> {
protected store:CollectionStore<T> = this.createStore();
@ -225,8 +229,14 @@ export abstract class ResourceCollectionService<T extends { id:ID }> {
/**
* Fetch a given collection, ensuring it is being flagged as loaded
*
* @param params {ApiV3ListParameters|string} collection key or list params to build collection key from
* @param options {ResourceCollectionLoadOptions} Handle collection loading errors within the resource service
*/
fetchCollection(params:ApiV3ListParameters|string):Observable<IHALCollection<T>> {
fetchCollection(
params:ApiV3ListParameters|string,
options:ResourceCollectionLoadOptions = { handleErrors: true },
):Observable<IHALCollection<T>> {
const key = typeof params === 'string' ? params : collectionKey(params);
setCollectionLoading(this.store, key);
@ -238,7 +248,10 @@ export abstract class ResourceCollectionService<T extends { id:ID }> {
tap((collection) => insertCollectionIntoState(this.store, collection, key)),
finalize(() => removeCollectionLoading(this.store, key)),
catchError((error:unknown) => {
this.handleCollectionLoadingError(error as HttpErrorResponse, key);
if (options.handleErrors) {
this.handleCollectionLoadingError(error as HttpErrorResponse, key);
}
throw error;
}),
);
@ -256,6 +269,12 @@ export abstract class ResourceCollectionService<T extends { id:ID }> {
*/
protected abstract basePath():string;
/**
* By default, add a toast error in case of loading errors
* @param error
* @param _collectionKey
* @protected
*/
protected handleCollectionLoadingError(error:HttpErrorResponse, _collectionKey:string):void {
this.toastService.addError(error);
}

@ -38,9 +38,10 @@ export interface IHalErrorBase {
errorIdentifier:string;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-explicit-any
export function isHalError(err:any):err is IHalErrorBase {
return '_type' in err && 'message' in err && 'errorIdentifier' in err;
export function isHalError(err:unknown):err is IHalErrorBase {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
const hasOwn = (key:string):boolean => Object.prototype.hasOwnProperty.call(err, key);
return !!err && hasOwn('_type') && hasOwn('message') && hasOwn('errorIdentifier');
}
export interface IHalSingleError extends IHalErrorBase {

@ -1,6 +1,8 @@
import { Injectable } from '@angular/core';
import { IanBellStore } from './ian-bell.store';
import { InAppNotificationsResourceService } from 'core-app/core/state/in-app-notifications/in-app-notifications.service';
import {
InAppNotificationsResourceService,
} from 'core-app/core/state/in-app-notifications/in-app-notifications.service';
import { IAN_FACET_FILTERS } from 'core-app/features/in-app-notifications/center/state/ian-center.store';
import {
map,
@ -17,7 +19,10 @@ import {
EffectCallback,
EffectHandler,
} from 'core-app/core/state/effects/effect-handler.decorator';
import { notificationsMarkedRead, notificationCountIncreased } from 'core-app/core/state/in-app-notifications/in-app-notifications.actions';
import {
notificationsMarkedRead,
notificationCountIncreased,
} from 'core-app/core/state/in-app-notifications/in-app-notifications.actions';
import { ActionsService } from 'core-app/core/state/actions/actions.service';
/**
@ -48,7 +53,10 @@ export class IanBellService {
fetchUnread():Observable<number> {
return this
.resourceService
.fetchCollection({ filters: IAN_FACET_FILTERS.unread, pageSize: 0 })
.fetchCollection(
{ filters: IAN_FACET_FILTERS.unread, pageSize: 0 },
{ handleErrors: false },
)
.pipe(
map((result) => result.total),
tap(

@ -81,10 +81,6 @@ export abstract class FilePickerBaseModalComponent extends OpModalComponent impl
public readonly loading$ = new BehaviorSubject<boolean>(true);
public get location():string {
return this.currentDirectory.location;
}
protected constructor(
@Inject(OpModalLocalsToken) public locals:OpModalLocalsMap,
readonly elementRef:ElementRef,

@ -66,6 +66,10 @@ export class LocationPickerModalComponent extends FilePickerBaseModalComponent {
},
};
public get location():string {
return this.currentDirectory.id as string;
}
public get canChooseLocation():boolean {
if (!this.currentDirectory) {
return false;

@ -54,7 +54,6 @@ import {
import {
LoadingFileListComponent,
} from 'core-app/shared/components/storages/loading-file-list/loading-file-list.component';
import { UploadStorageFilesService } from 'core-app/shared/components/storages/services/upload-storage-files.service';
@NgModule({
imports: [
@ -79,7 +78,6 @@ import { UploadStorageFilesService } from 'core-app/shared/components/storages/s
providers: [
SortFilesPipe,
CookieService,
UploadStorageFilesService,
],
})
export class OpenprojectStoragesModule {}

@ -1,130 +0,0 @@
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2022 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 COPYRIGHT and LICENSE files for more details.
//++
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { IUploadLink } from 'core-app/core/state/storage-files/upload-link.model';
import { IStorageFile } from 'core-app/core/state/storage-files/storage-file.model';
import { TimezoneService } from 'core-app/core/datetime/timezone.service';
@Injectable()
export class UploadStorageFilesService {
constructor(
private readonly httpClient:HttpClient,
private readonly timezoneService:TimezoneService,
) {}
public uploadFile(uploadLink:IUploadLink, file:File):Observable<IStorageFile> {
const url = new URL(uploadLink._links.destination.href);
const token = url.username;
const password = url.password;
url.username = '';
url.password = '';
const headers = {
Authorization: `Basic ${btoa(`${token}:${password}`)}`,
'X-External-Request': 'true',
};
const method = uploadLink._links.destination.method;
return this.httpClient
.request(method, url.toString(), { body: file, headers })
.pipe(
switchMap(() => this.httpClient.request(
'propfind',
url.toString(),
{
body: this.propfindBody,
headers,
responseType: 'text',
},
)),
map((xml) => this.parseXmlResponse(xml)),
);
}
private parseXmlResponse(xml:string):IStorageFile {
const response = new DOMParser().parseFromString(xml, 'application/xml');
const error = new Error(`Invalid response for uploaded file: ${xml}`);
const id = response.getElementsByTagName('oc:fileid')[0].textContent;
if (!id) { throw error; }
const mimeType = response.getElementsByTagName('d:getcontenttype')[0].textContent;
if (!mimeType) { throw error; }
const size = response.getElementsByTagName('oc:size')[0].textContent;
if (!size) { throw error; }
const href = response.getElementsByTagName('d:href')[0].textContent;
const parts = href?.split('/');
if (!parts || parts.length < 1) { throw error; }
const name = parts.pop();
if (!name) { throw error; }
const location = `/${parts.slice(parts.indexOf('webdav') + 1).join('/')}`;
const date = response.getElementsByTagName('d:getlastmodified')[0].textContent;
if (!date) { throw error; }
const createdAt = this.timezoneService.parseDatetime(date).toISOString();
const lastModifiedAt = createdAt;
const creator = response.getElementsByTagName('oc:owner-display-name')[0].textContent;
if (!creator) { throw error; }
return {
id,
name: decodeURIComponent(name),
location,
mimeType,
size: parseInt(size, 10),
createdAt,
createdByName: creator,
lastModifiedAt,
lastModifiedByName: creator,
permissions: [],
};
}
private get propfindBody() {
return `<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:prop>
<oc:fileid />
<d:getlastmodified />
<d:getcontenttype />
<oc:size />
<oc:owner-display-name />
</d:prop>
</d:propfind>`;
}
}

@ -36,13 +36,22 @@ import {
OnInit,
ViewChild,
} from '@angular/core';
import { HttpClient, HttpEventType, HttpResponse } from '@angular/common/http';
import { BehaviorSubject, Observable, throwError } from 'rxjs';
import { catchError, switchMap, take } from 'rxjs/operators';
import {
catchError,
filter,
map,
share,
switchMap,
take,
tap,
} from 'rxjs/operators';
import { CookieService } from 'ngx-cookie-service';
import { v4 as uuidv4 } from 'uuid';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { IFileLink } from 'core-app/core/state/file-links/file-link.model';
import { IFileLink, IFileLinkOriginData } from 'core-app/core/state/file-links/file-link.model';
import { IPrepareUploadLink, IStorage } from 'core-app/core/state/storages/storage.model';
import { FileLinksResourceService } from 'core-app/core/state/file-links/file-links.service';
import {
@ -61,7 +70,6 @@ import {
StorageInformationBox,
} from 'core-app/shared/components/storages/storage-information/storage-information-box';
import { OpModalService } from 'core-app/shared/components/modal/modal.service';
import { ConfigurationService } from 'core-app/core/config/configuration.service';
import {
FilePickerModalComponent,
} from 'core-app/shared/components/storages/file-picker-modal/file-picker-modal.component';
@ -69,10 +77,11 @@ import { IHalResourceLink } from 'core-app/core/state/hal-resource';
import {
LocationPickerModalComponent,
} from 'core-app/shared/components/storages/location-picker-modal/location-picker-modal.component';
import { UploadStorageFilesService } from 'core-app/shared/components/storages/services/upload-storage-files.service';
import { ToastService } from 'core-app/shared/components/toaster/toast.service';
import { UploadFile } from 'core-app/core/file-upload/op-file-upload.service';
import { StorageFilesResourceService } from 'core-app/core/state/storage-files/storage-files.service';
import { IUploadLink } from 'core-app/core/state/storage-files/upload-link.model';
import { TimezoneService } from 'core-app/core/datetime/timezone.service';
@Component({
selector: 'op-storage',
@ -127,6 +136,8 @@ export class StorageComponent extends UntilDestroyedMixin implements OnInit, OnD
uploadFailed: (fileName:string):string => this.i18n.t('js.storages.file_links.upload_error', { fileName }),
linkingAfterUploadFailed: (fileName:string, workPackageId:string):string =>
this.i18n.t('js.storages.file_links.link_uploaded_file_error', { fileName, workPackageId }),
draggingManyFiles: (storageType:string):string => this.i18n.t('js.storages.file.dragging_many_files', { storageType }),
uploadingLabel: this.i18n.t('js.label_upload_notification'),
},
dropBox: {
uploadLabel: this.i18n.t('js.storages.upload_files'),
@ -135,6 +146,7 @@ export class StorageComponent extends UntilDestroyedMixin implements OnInit, OnD
},
emptyList: ():string => this.i18n.t('js.storages.file_links.empty', { storageType: this.storageType }),
openStorage: ():string => this.i18n.t('js.storages.open_storage', { storageType: this.storageType }),
nextcloud: this.i18n.t('js.storages.types.nextcloud'),
};
public get storageFilesLocation():string {
@ -163,13 +175,13 @@ export class StorageComponent extends UntilDestroyedMixin implements OnInit, OnD
constructor(
private readonly i18n:I18nService,
private readonly cdRef:ChangeDetectorRef,
private readonly http:HttpClient,
private readonly toastService:ToastService,
private readonly cookieService:CookieService,
private readonly opModalService:OpModalService,
private readonly timezoneService:TimezoneService,
private readonly currentUserService:CurrentUserService,
private readonly configurationService:ConfigurationService,
private readonly fileLinkResourceService:FileLinksResourceService,
private readonly uploadStorageFilesService:UploadStorageFilesService,
private readonly storageFilesResourceService:StorageFilesResourceService,
) {
super();
@ -266,13 +278,13 @@ export class StorageComponent extends UntilDestroyedMixin implements OnInit, OnD
});
}
private uploadFile(file:UploadFile, location:string):void {
private uploadFile(file:UploadFile, locationId:string):void {
let isUploadError = false;
this.storageFilesResourceService
.uploadLink(this.uploadResourceLink(file.name, location))
.uploadLink(this.uploadResourceLink(file.name, locationId))
.pipe(
switchMap((link) => this.uploadStorageFilesService.uploadFile(link, file)),
switchMap((link) => this.uploadAndNotify(link, file)),
catchError((error) => {
isUploadError = true;
return throwError(error);
@ -300,6 +312,58 @@ export class StorageComponent extends UntilDestroyedMixin implements OnInit, OnD
);
}
private uploadAndNotify(link:IUploadLink, file:UploadFile):Observable<IFileLinkOriginData> {
const { method, href } = link._links.destination;
interface FileUploadResponse {
file_name:string;
file_id:string;
}
const formData = new FormData();
formData.append('file', file, file.name);
const observable = this.http.request<FileUploadResponse>(
method,
href,
{
body: formData,
headers: { 'X-External-Request': 'true' },
observe: 'events',
reportProgress: true,
responseType: 'json',
},
).pipe(share());
const notification = this.toastService.add({
data: [[file, observable]],
type: 'upload',
message: this.text.toast.uploadingLabel,
});
return observable
.pipe(
tap(() => {
setTimeout(() => this.toastService.remove(notification), 700);
}),
filter((ev) => ev.type === HttpEventType.Response),
map((ev:HttpResponse<FileUploadResponse>) => ev.body),
map((data) => {
if (data === null) {
throw new Error('Upload data is null.');
}
const now = this.timezoneService.parseDate(new Date()).toISOString();
return ({
id: data.file_id,
name: data.file_name,
mimeType: file.type,
size: file.size,
createdAt: now,
lastModifiedAt: now,
});
}),
);
}
private uploadResourceLink(fileName:string, location:string):IPrepareUploadLink {
const project = (this.resource.project as unknown&{ id:string }).id;
const link = this.storage._links.prepareUpload.filter((value) => project === value.payload.projectId.toString());
@ -401,7 +465,7 @@ export class StorageComponent extends UntilDestroyedMixin implements OnInit, OnD
}
private initializeStorageTypes() {
this.storageTypeMap[nextcloud] = this.i18n.t('js.storages.types.nextcloud');
this.storageTypeMap[nextcloud] = this.text.nextcloud;
}
public onDropFiles(event:DragEvent):void {
@ -409,6 +473,14 @@ export class StorageComponent extends UntilDestroyedMixin implements OnInit, OnD
this.draggingOverDropZone = false;
this.dragging = 0;
const files = event.dataTransfer.files;
if (files.length !== 1) {
this.toastService.addError(this.text.toast.draggingManyFiles(this.storageType));
return;
}
this.openSelectLocationDialog(files);
}
public onDragOver(event:DragEvent):void {

@ -18,7 +18,7 @@
<span [textContent]="' '"></span>
<a class="op-toast--target-link"
*ngIf="toast.link"
(click)="executeTarget($event)"
(click)="executeTarget()"
[textContent]="toast.link!.text">
</a>
</p>
@ -37,9 +37,10 @@
<ul class="op-toast--uploads" *ngIf="data && data.length > 0">
<op-toasters-upload-progress
*ngFor="let upload of data"
[upload]="upload"
(onSuccess)="onUploadSuccess()"
(onError)="remove()">
[file]="upload[0]"
[upload]="upload[1]"
(uploadSuccess)="onUploadSuccess()"
(uploadError)="remove()">
</op-toasters-upload-progress>
</ul>
</div>

@ -27,13 +27,25 @@
//++
import {
Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild,
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
} from '@angular/core';
import { HttpErrorResponse, HttpEventType, HttpProgressEvent } from '@angular/common/http';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import {
HttpErrorResponse,
HttpEvent,
HttpEventType,
HttpProgressEvent,
} from '@angular/common/http';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { debugLog } from 'core-app/shared/helpers/debug_output';
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
import { UploadFile, UploadHttpEvent, UploadInProgress } from 'core-app/core/file-upload/op-file-upload.service';
@Component({
selector: 'op-toasters-upload-progress',
@ -48,78 +60,80 @@ import { UploadFile, UploadHttpEvent, UploadInProgress } from 'core-app/core/fil
</span>
</li>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UploadProgressComponent extends UntilDestroyedMixin implements OnInit {
@Input() public upload:UploadInProgress;
export class UploadProgressComponent extends UntilDestroyedMixin implements OnInit, AfterViewInit {
@Input() public file:File;
@Output() public onError = new EventEmitter<HttpErrorResponse>();
@Input() public upload:Observable<HttpEvent<unknown>>;
@Output() public onSuccess = new EventEmitter<undefined>();
@Output() public uploadError = new EventEmitter<HttpErrorResponse>();
@ViewChild('progressBar')
progressBar:ElementRef;
@Output() public uploadSuccess = new EventEmitter<void>();
@ViewChild('progressPercentage')
progressPercentage:ElementRef;
@ViewChild('progressBar') progressBar:ElementRef;
public file:UploadFile;
@ViewChild('progressPercentage') progressPercentage:ElementRef;
public error = false;
public completed = false;
private viewInitialized = new BehaviorSubject<boolean>(false);
set value(value:number) {
this.progressBar.nativeElement.value = value;
this.progressPercentage.nativeElement.innerText = `${value}%`;
(this.progressBar.nativeElement as HTMLProgressElement).value = value;
(this.progressPercentage.nativeElement as HTMLParagraphElement).innerText = `${value}%`;
if (value === 100) {
this.progressBar.nativeElement.style.display = 'none';
(this.progressBar.nativeElement as HTMLElement).style.display = 'none';
}
}
constructor(protected readonly I18n:I18nService) {
super();
}
ngOnInit() {
this.file = this.upload[0];
const observable = this.upload[1];
observable
.pipe(
this.untilDestroyed(),
)
combineLatest([
this.upload,
this.viewInitialized,
]).pipe(this.untilDestroyed())
.subscribe(
(evt:UploadHttpEvent) => {
([evt, initialized]) => {
if (!initialized) {
return;
}
switch (evt.type) {
case HttpEventType.Sent:
this.value = 5;
return debugLog(`Uploading file "${this.file.name}" of size ${this.file.size}.`);
debugLog(`Uploading file "${this.file.name}" of size ${this.file.size}.`);
break;
case HttpEventType.UploadProgress:
return this.updateProgress(evt);
this.updateProgress(evt);
break;
case HttpEventType.Response:
debugLog(`File ${this.fileName} was fully uploaded.`);
this.value = 100;
this.completed = true;
return this.onSuccess.emit();
this.uploadSuccess.emit();
break;
default:
// Sent or unknown event
console.warn(`unknown event type: ${evt.type}`);
}
},
(error:HttpErrorResponse) => this.handleError(error),
);
}
public get fileName():string|undefined {
ngAfterViewInit():void {
this.viewInitialized.next(true);
}
public get fileName():string {
return this.file && this.file.name;
}
private updateProgress(evt:HttpProgressEvent) {
if (evt.total) {
this.value = Math.round(evt.loaded / evt.total * 100);
this.value = Math.round((evt.loaded / evt.total) * 100);
} else {
this.value = 10;
}
@ -127,6 +141,6 @@ export class UploadProgressComponent extends UntilDestroyedMixin implements OnIn
private handleError(error:HttpErrorResponse) {
this.error = true;
this.onError.emit(error);
this.uploadError.emit(error);
}
}

@ -116,7 +116,7 @@ export class AvatarUploadFormComponent implements OnInit {
}
},
(error:any) => {
this.toastService.addError(error.error);
this.toastService.addError(error);
this.busy = false;
}
);

@ -32,8 +32,8 @@ sv:
project_status:
title: 'Projektstatus'
not_started: 'Inte påbörjad'
on_track: 'På banan'
off_track: 'Utanför banan'
on_track: 'På rätt spår'
off_track: 'Urspårat'
at_risk: 'I riskzonen'
not_set: 'Ej inställd'
finished: 'Slutförd'

@ -0,0 +1,150 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 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 COPYRIGHT and LICENSE files for more details.
#++
module Storages::Peripherals::StorageInteraction::Nextcloud
class LegacyUploadLinkQuery < Storages::Peripherals::StorageInteraction::StorageQuery
using Storages::Peripherals::ServiceResultRefinements # use '>>' (bind) operator for ServiceResult
URI_BASE_PATH = '/ocs/v2.php/apps/files_sharing/api/v1/shares'.freeze
UPLOAD_LINK_BASE = '/public.php/webdav'.freeze
def initialize(base_uri:, token:, retry_proc:)
super()
@base_uri = base_uri
@token = token
@retry_proc = retry_proc
end
def query(data)
validated(data) >>
method(:create_file_share) >>
method(:apply_drop_permission) >>
method(:build_upload_link)
end
private
def validated(data)
if data.nil? || data['fileName'].nil? || data['parent'].nil?
error(:error, 'Data is invalid', data)
else
ServiceResult.success(
result: Struct.new(:file_name, :parent)
.new(data['fileName'], data['parent'])
)
end
end
def create_file_share(data)
password = SecureRandom.uuid
outbound_response(
method: :post,
relative_path: URI_BASE_PATH,
payload: {
shareType: 3,
password:,
path: data.parent,
expireDate: Date.tomorrow
}
).map do |response|
Struct.new(:id, :token, :password, :file_name)
.new(response.ocs.data.id, response.ocs.data.token, password, data.file_name)
end
end
def apply_drop_permission(share)
outbound_response(
method: :put,
relative_path: "#{URI_BASE_PATH}/#{share.id}",
payload: {
permissions: 5
}
).map { share }
end
def build_upload_link(share)
destination = @base_uri.merge("#{UPLOAD_LINK_BASE}/#{ERB::Util.url_encode(share.file_name)}")
destination.user = share.token
destination.password = share.password
ServiceResult.success(result: Storages::UploadLink.new(destination, :put))
end
def outbound_response(method:, relative_path:, payload:) # rubocop:disable Metrics/AbcSize
@retry_proc.call(@token) do |token|
begin
response = ServiceResult.success(
result: RestClient::Request.execute(
method:,
url: @base_uri.merge(relative_path).to_s,
payload: payload.to_json,
headers: {
'Authorization' => "Bearer #{token.access_token}",
'OCS-APIRequest' => 'true',
'Accept' => 'application/json',
'Content-Type' => 'application/json'
}
)
)
rescue RestClient::Unauthorized => e
response = error(:not_authorized, 'Outbound request not authorized!', e.response)
rescue RestClient::NotFound => e
response = error(:not_found, 'Outbound request destination not found!', e.response)
rescue RestClient::ExceptionWithResponse => e
response = error(:error, 'Outbound request failed!', e.response)
rescue StandardError
response = error(:error, 'Outbound request failed!')
end
# rubocop:disable Style/OpenStructUse
# rubocop:disable Style/MultilineBlockChain
response
.bind do |r|
# The nextcloud API returns a successful response with empty body if the authorization is missing or expired
if r.body.blank?
error(:not_authorized, 'Outbound request not authorized!')
else
ServiceResult.success(result: r)
end
end
.map { |r| JSON.parse(r.body, object_class: OpenStruct) }
# rubocop:enable Style/MultilineBlockChain
# rubocop:enable Style/OpenStructUse Style/MultilineBlockChain
end
end
def error(code, log_message = nil, data = nil)
ServiceResult.failure(
result: code, # This is needed to work with the ConnectionManager token refresh mechanism.
errors: Storages::StorageError.new(code:, log_message:, data:)
)
end
end
end

@ -30,75 +30,50 @@ module Storages::Peripherals::StorageInteraction::Nextcloud
class UploadLinkQuery < Storages::Peripherals::StorageInteraction::StorageQuery
using Storages::Peripherals::ServiceResultRefinements # use '>>' (bind) operator for ServiceResult
URI_BASE_PATH = '/ocs/v2.php/apps/files_sharing/api/v1/shares'.freeze
UPLOAD_LINK_BASE = '/public.php/webdav'.freeze
URI_TOKEN_REQUEST = 'apps/integration_openproject/direct-upload-token'.freeze
URI_UPLOAD_BASE_PATH = 'apps/integration_openproject/direct-upload'.freeze
def initialize(base_uri:, token:, retry_proc:, finalize_url:)
def initialize(base_uri:, token:, retry_proc:)
super()
@base_uri = base_uri
@token = token
@retry_proc = retry_proc
@finalize_url = finalize_url
end
def query(data)
validated(data) >>
method(:create_file_share) >>
method(:apply_drop_permission) >>
method(:request_direct_upload_token) >>
method(:build_upload_link)
end
private
def validated(data)
if data.nil? || data['fileName'].nil? || data['parent'].nil?
error(:error, 'Data is not valid', data)
if data.nil? || data['parent'].nil?
error(:error, 'Data is invalid', data)
else
ServiceResult.success(
result: Struct.new(:file_name, :parent)
.new(data['fileName'], data['parent'])
result: Struct.new(:parent).new(data['parent'])
)
end
end
def create_file_share(data)
password = SecureRandom.uuid
def request_direct_upload_token(data)
outbound_response(
method: :post,
relative_path: URI_BASE_PATH,
payload: {
shareType: 3,
password:,
path: data.parent,
expireDate: Date.tomorrow
}
).map do |response|
Struct.new(:id, :token, :password, :file_name)
.new(response.ocs.data.id, response.ocs.data.token, password, data.file_name)
end
end
def apply_drop_permission(share)
outbound_response(
method: :put,
relative_path: "#{URI_BASE_PATH}/#{share.id}",
payload: {
permissions: 5
}
).map { share }
relative_path: URI_TOKEN_REQUEST,
payload: { folder_id: data.parent }
)
end
def build_upload_link(share)
destination = @base_uri.merge("#{UPLOAD_LINK_BASE}/#{ERB::Util.url_encode(share.file_name)}")
destination.user = share.token
destination.password = share.password
def build_upload_link(response)
destination = @base_uri.merge(File.join(URI_UPLOAD_BASE_PATH, response.token))
ServiceResult.success(result: Storages::UploadLink.new(destination))
end
def outbound_response(method:, relative_path:, payload:) # rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/AbcSize
def outbound_response(method:, relative_path:, payload:)
@retry_proc.call(@token) do |token|
begin
response = ServiceResult.success(
@ -108,7 +83,6 @@ module Storages::Peripherals::StorageInteraction::Nextcloud
payload: payload.to_json,
headers: {
'Authorization' => "Bearer #{token.access_token}",
'OCS-APIRequest' => 'true',
'Accept' => 'application/json',
'Content-Type' => 'application/json'
}
@ -128,18 +102,19 @@ module Storages::Peripherals::StorageInteraction::Nextcloud
# rubocop:disable Style/MultilineBlockChain
response
.bind do |r|
# The nextcloud API returns a successful response with empty body if the authorization is missing or expired
if r.body.blank?
error(:not_authorized, 'Outbound request not authorized!')
else
ServiceResult.success(result: r)
end
# The nextcloud API returns a successful response with empty body if the authorization is missing or expired
if r.body.blank?
error(:not_authorized, 'Outbound request not authorized!')
else
ServiceResult.success(result: r)
end
end
.map { |r| JSON.parse(r.body, object_class: OpenStruct) }
# rubocop:enable Style/MultilineBlockChain
# rubocop:enable Style/OpenStructUse Style/MultilineBlockChain
end
end
# rubocop:enable Metrics/AbcSize
def error(code, log_message = nil, data = nil)
ServiceResult.failure(

@ -52,16 +52,23 @@ module Storages::Peripherals::StorageInteraction
end
end
def upload_link_query(finalize_url)
def upload_link_query
case @provider_type
when ::Storages::Storage::PROVIDER_TYPE_NEXTCLOUD
retry_with_refreshed_token do |token, with_refreshed_token_proc|
::Storages::Peripherals::StorageInteraction::Nextcloud::UploadLinkQuery.new(
base_uri: @uri,
token:,
retry_proc: with_refreshed_token_proc,
finalize_url:
)
if OpenProject::FeatureDecisions.legacy_upload_preparation_active?
::Storages::Peripherals::StorageInteraction::Nextcloud::LegacyUploadLinkQuery.new(
base_uri: @uri,
token:,
retry_proc: with_refreshed_token_proc
)
else
::Storages::Peripherals::StorageInteraction::Nextcloud::UploadLinkQuery.new(
base_uri: @uri,
token:,
retry_proc: with_refreshed_token_proc
)
end
end
else
raise ArgumentError

@ -45,10 +45,7 @@ module Storages::Peripherals::StorageInteraction
def upload_link_query(storage, user)
Storages::Peripherals::StorageRequests
.new(storage:)
.upload_link_query(
user:,
finalize_url: nil # ToDo: api_v3_paths.finalize_upload(@storage.id)
)
.upload_link_query(user:)
end
def execute_upload_link_query(request_body)

@ -45,9 +45,9 @@ module Storages::Peripherals
.map { |query| query.method(:query).to_proc }
end
def upload_link_query(user:, finalize_url:)
def upload_link_query(user:)
storage_queries(user)
.upload_link_query(finalize_url)
.upload_link_query
.map { |query| query.method(:query).to_proc }
end

@ -27,10 +27,10 @@
#++
class Storages::UploadLink
attr_reader :destination, :finalize
attr_reader :destination, :method
def initialize(destination = '', finalize = nil)
def initialize(destination = '', method = :post)
@destination = destination
@finalize = finalize
@method = method
end
end

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

Loading…
Cancel
Save