Merge pull request #7146 from opf/merge/table-refresh

Merging 8.3.1 into dev

[ci skip]
pull/7151/head
Oliver Günther 6 years ago committed by GitHub
commit 31b32d25b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 83
      Dockerfile
  2. 39
      Dockerfile.public
  3. 8
      Gemfile
  4. 90
      Gemfile.lock
  5. 3
      app/assets/stylesheets/openproject/_generic.sass
  6. 221
      app/controllers/search_controller.rb
  7. 11
      app/controllers/work_packages_controller.rb
  8. 20
      app/helpers/search_helper.rb
  9. 56
      app/models/custom_actions/conditions/role.rb
  10. 3
      app/models/mail_handler.rb
  11. 23
      app/models/queries/work_packages/filter/attachment_base_filter.rb
  12. 20
      app/models/queries/work_packages/filter/comment_filter.rb
  13. 4
      app/models/queries/work_packages/filter/manual_sort_filter.rb
  14. 4
      app/models/queries/work_packages/filter/or_filter_for_wp_mixin.rb
  15. 10
      app/models/queries/work_packages/filter/search_filter.rb
  16. 45
      app/models/queries/work_packages/filter/text_filter_on_join_mixin.rb
  17. 7
      app/models/query.rb
  18. 15
      app/models/query/results.rb
  19. 2
      app/views/homescreen/index.html.erb
  20. 2
      app/views/projects/form/attributes/_name.html.erb
  21. 15
      app/views/search/index.html.erb
  22. 10
      app/views/settings/_mail_handler.html.erb
  23. 18
      config/initializers/homescreen.rb
  24. 4
      config/locales/en.yml
  25. 2
      config/settings.yml
  26. 3
      docker/Procfile
  27. 3
      docker/console
  28. 50
      docker/entrypoint.sh
  29. 212
      docker/nginx.conf.erb
  30. 18
      docker/precompile-assets.sh
  31. 2
      docker/proxy
  32. 2
      docker/proxy.conf.erb
  33. 55
      docker/supervisord
  34. 59
      docker/supervisord.conf
  35. 161
      docker/wait-for-it.sh
  36. 1
      docker/web
  37. 12
      docs/configuration/configuration.md
  38. 20
      frontend/src/app/components/wp-buttons/wp-timeline-toggle-button/wp-timeline-toggle-button.html
  39. 2
      frontend/src/app/components/wp-relations/embedded/wp-relation-query.base.ts
  40. 21
      frontend/src/app/components/wp-table/embedded/wp-embedded-base.component.ts
  41. 2
      frontend/src/app/components/wp-table/embedded/wp-embedded-graph.component.ts
  42. 7
      frontend/src/app/components/wp-table/embedded/wp-embedded-table.component.ts
  43. 3
      frontend/src/app/components/wp-table/embedded/wp-embedded-table.html
  44. 3
      frontend/src/app/components/wp-table/timeline/header/wp-timeline-header.directive.ts
  45. 40
      frontend/src/app/modules/global_search/global-search-work-packages.component.ts
  46. 21
      frontend/src/app/modules/work_packages/routing/wp-view-base/work-packages-view.base.ts
  47. 3
      lib/api/v3/work_packages/work_package_eager_loading_wrapper.rb
  48. 12
      lib/open_project/configuration.rb
  49. 17
      lib/open_project/database.rb
  50. 34
      lib/open_project/static/links.rb
  51. 2
      lib/open_project/text_formatting/helpers/link_rewriter.rb
  52. 7
      lib/redmine/menu_manager/top_menu/help_menu.rb
  53. 8
      modules/auth_saml/lib/open_project/auth_saml/engine.rb
  54. 2
      modules/avatars/lib/open_project/avatars/patches/avatar_helper_patch.rb
  55. 2
      modules/costs/app/controllers/costlog_controller.rb
  56. 10
      modules/costs/app/views/cost_objects/_show_variable_cost_object.html.erb
  57. 5
      modules/costs/lib/open_project/costs/hooks/work_packages_show_attributes.rb
  58. 4
      modules/grids/spec/features/my/my_page_assigned_to_me_spec.rb
  59. 4
      modules/reporting_engine/lib/widget/table/report_table.rb
  60. 6
      spec/controllers/search_controller_spec.rb
  61. 9
      spec/helpers/search_helper_spec.rb
  62. 8
      spec/lib/database_spec.rb
  63. 20
      spec/models/mail_handler_spec.rb
  64. 2
      spec/models/queries/work_packages/filter/attachment_content_filter_spec.rb
  65. 12
      spec/models/queries/work_packages/filter/search_filter_spec.rb
  66. 9
      spec/models/query_spec.rb

@ -1,36 +1,56 @@
FROM ruby:2.6-stretch
MAINTAINER operations@openproject.com
ENV NODE_VERSION="10.15.0"
ENV BUNDLER_VERSION="2.0.1"
ENV NODE_VERSION "10.15.0"
ENV BUNDLER_VERSION "2.0.1"
ENV APP_USER app
ENV APP_PATH /usr/src/app
ENV APP_DATA /var/db/openproject
ENV ATTACHMENTS_STORAGE_PATH /var/db/openproject/files
ENV APP_PATH /app
ENV APP_DATA_PATH /var/openproject/assets
ENV APP_DATA_PATH_LEGACY /var/db/openproject
ENV PGDATA /var/openproject/pgdata
ENV PGDATA_LEGACY /var/lib/postgresql/9.6/main
ENV DATABASE_URL postgres://openproject:openproject@127.0.0.1/openproject
ENV RAILS_ENV production
ENV HEROKU true
ENV RAILS_CACHE_STORE memcache
ENV OPENPROJECT_INSTALLATION__TYPE docker
ENV NEW_RELIC_AGENT_ENABLED false
ENV ATTACHMENTS_STORAGE_PATH $APP_DATA_PATH/files
# Set a default key base, ensure to provide a secure value in production environments!
ENV SECRET_KEY_BASE=OVERWRITE_ME
ENV SECRET_KEY_BASE OVERWRITE_ME
# install node + npm
RUN curl https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.gz | tar xzf - -C /usr/local --strip-components=1
RUN apt-get update -qq && \
DEBIAN_FRONTEND=noninteractive apt-get install -y \
postgresql-client \
mysql-client \
sqlite \
poppler-utils \
unrtf \
tesseract-ocr \
catdoc && \
apt-get clean && rm -rf /var/lib/apt/lists/*
DEBIAN_FRONTEND=noninteractive apt-get install -y \
postgresql-client \
poppler-utils \
unrtf \
tesseract-ocr \
catdoc \
memcached \
postfix \
postgresql \
apache2 \
supervisor && \
apt-get clean && rm -rf /var/lib/apt/lists/*
# Set up pg defaults
RUN echo "host all all 0.0.0.0/0 md5" >> /etc/postgresql/9.6/main/pg_hba.conf
RUN echo "listen_addresses='*'" >> /etc/postgresql/9.6/main/postgresql.conf
RUN echo "data_directory='$PGDATA'" >> /etc/postgresql/9.6/main/postgresql.conf
RUN rm -rf "$PGDATA_LEGACY" && rm -rf "$PGDATA" && mkdir -p "$PGDATA" && chown -R postgres:postgres "$PGDATA"
RUN a2enmod proxy proxy_http && rm -f /etc/apache2/sites-enabled/000-default.conf
# using /home/app since npm cache and other stuff will be put there when running npm install
# we don't want to pollute any locally-mounted directory
RUN useradd -d /home/$APP_USER -m $APP_USER
RUN mkdir -p $APP_PATH $APP_DATA
RUN gem install bundler --version "${bundler_version}" --no-document
WORKDIR $APP_PATH
RUN gem install bundler --version "${bundler_version}" --no-document
COPY Gemfile ./Gemfile
COPY Gemfile.* ./
@ -38,34 +58,27 @@ COPY modules ./modules
# OpenProject::Version is required by module versions in gemspecs
RUN mkdir -p lib/open_project
COPY lib/open_project/version.rb ./lib/open_project/
RUN bundle install --deployment --with="docker opf_plugins" --without="test development" --jobs=8 --retry=3
# Then, npm install node modules
COPY package.json /tmp/npm/package.json
COPY frontend/*.json /tmp/npm/frontend/
RUN cd /tmp/npm/frontend/ && RAILS_ENV=production npm install && mv /tmp/npm/frontend /usr/src/app/
RUN bundle install --deployment --with="docker opf_plugins" --without="test development mysql2" --jobs=8 --retry=3
# Finally, copy over the whole thing
COPY . /usr/src/app
RUN cp docker/Procfile .
COPY . $APP_PATH
RUN sed -i "s|Rails.groups(:opf_plugins)|Rails.groups(:opf_plugins, :docker)|" config/application.rb
# Ensure we can write in /tmp/op_uploaded_files (cf. #29112)
RUN mkdir -p /tmp/op_uploaded_files/
RUN chown -R $APP_USER:$APP_USER /tmp/op_uploaded_files/
# Allow uploading avatars w/ postgres
RUN chown -R $APP_USER:$APP_USER $APP_DATA
RUN mkdir -p /tmp/op_uploaded_files/ && chown -R $APP_USER:$APP_USER /tmp/op_uploaded_files/
# Re-use packager database.yml
COPY packaging/conf/database.yml ./config/database.yml
# Run the npm postinstall manually after it was copied
RUN DATABASE_URL=sqlite3:///tmp/db.sqlite3 RAILS_ENV=production bundle exec rake assets:precompile
# Then, npm install node modules
RUN bash docker/precompile-assets.sh
# Include pandoc
RUN DATABASE_URL=sqlite3:///tmp/db.sqlite3 RAILS_ENV=production bundle exec rails runner "puts ::OpenProject::TextFormatting::Formats::Markdown::PandocDownloader.check_or_download!"
# ports
EXPOSE 80 5432
CMD ["./docker/web"]
# volumes to export
VOLUME ["$PGDATA", "$APP_DATA_PATH"]
ENTRYPOINT ["./docker/entrypoint.sh"]
VOLUME ["$APP_DATA"]
CMD ["./docker/supervisord"]

@ -1,39 +0,0 @@
FROM openproject/community:8-base
MAINTAINER operations@openproject.com
ENV DATABASE_URL=postgres://openproject:openproject@127.0.0.1/openproject
ENV RAILS_ENV=production
ENV HEROKU=true
ENV ATTACHMENTS_STORAGE_PATH=/var/db/openproject/files
ENV RAILS_CACHE_STORE=memcache
ENV SECRET_KEY_BASE=OVERWRITE_ME
ENV OPENPROJECT_INSTALLATION__TYPE=docker
USER root
RUN apt-get update -qq && \
DEBIAN_FRONTEND=noninteractive apt-get install -y \
memcached \
postfix \
postgresql \
apache2 \
supervisor \
pandoc && \
apt-get clean && rm -rf /var/lib/apt/lists/*
RUN a2enmod proxy proxy_http && rm -f /etc/apache2/sites-enabled/000-default.conf
RUN echo "host all all 0.0.0.0/0 md5" >> /etc/postgresql/9.6/main/pg_hba.conf
RUN echo "listen_addresses='*'" >> /etc/postgresql/9.6/main/postgresql.conf
RUN rm -rf /var/lib/postgresql/9.6/main && mkdir -p /var/lib/postgresql/9.6/main && chown -R postgres:postgres /var/lib/postgresql/9.6
RUN mkdir -p /var/db/openproject/{files,git,svn} && chown -R app:app /var/db/openproject
COPY docker /usr/src/app/docker
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# ports
EXPOSE 80 5432
# volumes to export
VOLUME ["/var/lib/postgresql/9.6/main", "/var/db/openproject"]
ENTRYPOINT ["/usr/src/app/docker/entrypoint-all-in-one.sh"]

@ -34,7 +34,7 @@ gem 'actionpack-xml_parser', '~> 2.0.0'
gem 'activemodel-serializers-xml', '~> 1.0.1'
gem 'activerecord-import', '~> 0.28.1'
gem 'activerecord-session_store', '~> 1.1.0'
gem 'rails', '~> 5.2.2'
gem 'rails', '~> 5.2.2.1'
gem 'responders', '~> 2.4'
gem 'rdoc', '>= 2.4.2'
@ -153,7 +153,9 @@ group :production do
# we use dalli as standard memcache client
# requires memcached 1.4+
# see https://github.clientom/mperham/dalli
gem 'dalli', '~> 2.7.6'
gem 'dalli',
git: 'https://github.com/petergoldstein/dalli',
ref: '0ff39199b5e91c6dbdaabc7c085b81938d0f08d2'
# Unicorn worker killer to restart unicorn child workers
gem 'unicorn-worker-killer', require: false
@ -304,8 +306,6 @@ group :docker, optional: true do
gem 'health_check', require: !!ENV['HEROKU']
gem 'newrelic_rpm', require: !!ENV['HEROKU']
gem 'rails_12factor', require: !!ENV['HEROKU']
# Require specific version of sqlite3 for rails
gem 'sqlite3', '~> 1.3.6', require: false
end
# Load Gemfile.local, Gemfile.plugins, plugins', and custom Gemfiles

@ -74,6 +74,13 @@ GIT
mixlib-shellout (~> 2.1.0)
rubyzip
GIT
remote: https://github.com/petergoldstein/dalli
revision: 0ff39199b5e91c6dbdaabc7c085b81938d0f08d2
ref: 0ff39199b5e91c6dbdaabc7c085b81938d0f08d2
specs:
dalli (2.7.9)
GIT
remote: https://github.com/rspec/rspec-activemodel-mocks
revision: 6136a778f8b21f4f45f6b4ad5c2e2533e6d4ddc6
@ -216,19 +223,19 @@ GEM
remote: https://rubygems.org/
specs:
Ascii85 (1.0.3)
actioncable (5.2.2)
actionpack (= 5.2.2)
actioncable (5.2.2.1)
actionpack (= 5.2.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailer (5.2.2)
actionpack (= 5.2.2)
actionview (= 5.2.2)
activejob (= 5.2.2)
actionmailer (5.2.2.1)
actionpack (= 5.2.2.1)
actionview (= 5.2.2.1)
activejob (= 5.2.2.1)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (5.2.2)
actionview (= 5.2.2)
activesupport (= 5.2.2)
actionpack (5.2.2.1)
actionview (= 5.2.2.1)
activesupport (= 5.2.2.1)
rack (~> 2.0)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
@ -236,24 +243,24 @@ GEM
actionpack-xml_parser (2.0.1)
actionpack (>= 5.0)
railties (>= 5.0)
actionview (5.2.2)
activesupport (= 5.2.2)
actionview (5.2.2.1)
activesupport (= 5.2.2.1)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.3)
activejob (5.2.2)
activesupport (= 5.2.2)
activejob (5.2.2.1)
activesupport (= 5.2.2.1)
globalid (>= 0.3.6)
activemodel (5.2.2)
activesupport (= 5.2.2)
activemodel (5.2.2.1)
activesupport (= 5.2.2.1)
activemodel-serializers-xml (1.0.2)
activemodel (> 5.x)
activesupport (> 5.x)
builder (~> 3.1)
activerecord (5.2.2)
activemodel (= 5.2.2)
activesupport (= 5.2.2)
activerecord (5.2.2.1)
activemodel (= 5.2.2.1)
activesupport (= 5.2.2.1)
arel (>= 9.0)
activerecord-import (0.28.1)
activerecord (>= 3.2)
@ -265,11 +272,11 @@ GEM
multi_json (~> 1.11, >= 1.11.2)
rack (>= 1.5.2, < 3)
railties (>= 4.0)
activestorage (5.2.2)
actionpack (= 5.2.2)
activerecord (= 5.2.2)
activestorage (5.2.2.1)
actionpack (= 5.2.2.1)
activerecord (= 5.2.2.1)
marcel (~> 0.3.1)
activesupport (5.2.2)
activesupport (5.2.2.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
minitest (~> 5.1)
@ -364,7 +371,7 @@ GEM
descendants_tracker (~> 0.0.1)
commonmarker (0.17.9)
ruby-enum (~> 0.5)
concurrent-ruby (1.1.4)
concurrent-ruby (1.1.5)
crack (0.4.3)
safe_yaml (~> 1.0.0)
crass (1.0.4)
@ -393,7 +400,6 @@ GEM
cucumber-tag_expressions (1.1.1)
cucumber-wire (0.0.1)
daemons (1.3.1)
dalli (2.7.7)
database_cleaner (1.7.0)
date_validator (0.9.0)
activemodel
@ -490,7 +496,7 @@ GEM
http-cookie (1.0.3)
domain_name (~> 0.5)
httpclient (2.8.3)
i18n (1.5.3)
i18n (1.6.0)
concurrent-ruby (~> 1.0)
i18n-js (3.2.1)
i18n (>= 0.6.6)
@ -656,18 +662,18 @@ GEM
rack_session_access (0.2.0)
builder (>= 2.0.0)
rack (>= 1.0.0)
rails (5.2.2)
actioncable (= 5.2.2)
actionmailer (= 5.2.2)
actionpack (= 5.2.2)
actionview (= 5.2.2)
activejob (= 5.2.2)
activemodel (= 5.2.2)
activerecord (= 5.2.2)
activestorage (= 5.2.2)
activesupport (= 5.2.2)
rails (5.2.2.1)
actioncable (= 5.2.2.1)
actionmailer (= 5.2.2.1)
actionpack (= 5.2.2.1)
actionview (= 5.2.2.1)
activejob (= 5.2.2.1)
activemodel (= 5.2.2.1)
activerecord (= 5.2.2.1)
activestorage (= 5.2.2.1)
activesupport (= 5.2.2.1)
bundler (>= 1.3.0)
railties (= 5.2.2)
railties (= 5.2.2.1)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.4)
actionpack (>= 5.0.1.x)
@ -683,9 +689,9 @@ GEM
rails_stdout_logging
rails_serve_static_assets (0.0.5)
rails_stdout_logging (0.0.5)
railties (5.2.2)
actionpack (= 5.2.2)
activesupport (= 5.2.2)
railties (5.2.2.1)
actionpack (= 5.2.2.1)
activesupport (= 5.2.2.1)
method_source
rake (>= 0.8.7)
thor (>= 0.19.0, < 2.0)
@ -820,7 +826,6 @@ GEM
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets (>= 3.0.0)
sqlite3 (1.3.13)
stackprof (0.2.12)
stringex (2.8.5)
svg-graph (2.1.3)
@ -922,7 +927,7 @@ DEPENDENCIES
cucumber (~> 3.1.0)
cucumber-rails (~> 1.6.0)
daemons
dalli (~> 2.7.6)
dalli!
database_cleaner (~> 1.6)
date_validator (~> 0.9.0)
delayed_job_active_record (~> 4.1.1)
@ -999,7 +1004,7 @@ DEPENDENCIES
rack-protection (~> 2.0.0)
rack-test (~> 1.1.0)
rack_session_access
rails (~> 5.2.2)
rails (~> 5.2.2.1)
rails-controller-testing (~> 1.0.2)
rails_12factor
rdoc (>= 2.4.2)
@ -1035,7 +1040,6 @@ DEPENDENCIES
spring
spring-commands-rspec
sprockets (~> 3.7.0)
sqlite3 (~> 1.3.6)
stackprof
stringex (~> 2.8.5)
svg-graph (~> 2.1.0)

@ -66,6 +66,9 @@
.total-hours
font-weight: bold
.-break-word
word-wrap: break-word
.ellipsis,
.form--field.ellipsis .form--label
@include text-shortener

@ -30,116 +30,177 @@
class SearchController < ApplicationController
include Concerns::Layout
before_action :find_optional_project
before_action :find_optional_project,
:prepare_tokens,
:quick_wp_id_redirect
LIMIT = 10
def index
@question = search_params[:q] || ''
@question.strip!
if @tokens.any?
@results, @results_count = search_results(@tokens)
projects_to_search =
case search_params[:scope]
when 'all'
nil
when 'current_project'
@project
if search_params[:previous].nil?
limit_results_first_page
else
@project ? (@project.self_and_descendants.active) : nil
limit_results_subsequent_page
end
offset = begin
Time.at(Rational(search_params[:offset])) if search_params[:offset]
rescue; end
# quick jump to an work_package
scan_work_package_reference @question do |id|
return redirect_to work_package_path(id: id) if WorkPackage.visible.find_by(id: id.to_i)
end
@object_types = Redmine::Search.available_search_types.dup
if projects_to_search.is_a? Project
# don't search projects
@object_types.delete('projects')
# only show what the user is allowed to view
@object_types = @object_types.select { |o| User.current.allowed_to?("view_#{o}".to_sym, projects_to_search) }
end
provision_gon
# The work package search is done differntly, so put it into the search scope.
@scope = @object_types.select { |t| search_params[t] && t != 'work_packages' }
@scope = @object_types if @scope.empty?
render layout: layout_non_or_no_menu
end
private
# extract tokens from the question
# eg. hello "bye bye" => ["hello", "bye bye"]
def prepare_tokens
@question = search_params[:q] || ''
@question.strip!
@tokens = scan_query_tokens(@question).uniq
if @tokens.any?
# no more than 5 tokens to search for
@tokens.slice! 5..-1 if @tokens.size > 5
@results = []
@results_by_type = Hash.new { |h, k| h[k] = 0 }
limit = 10
@scope.each do |s|
r, c = s.singularize.camelcase.constantize.search(@tokens, projects_to_search,
limit: (limit + 1),
offset: offset,
before: search_params[:previous].nil?)
@results += r
@results_by_type[s] += c
end
@results = @results.sort { |a, b| b.event_datetime <=> a.event_datetime }
if search_params[:previous].nil?
@pagination_previous_date = @results[0].event_datetime if offset && @results[0]
if @results.size > limit
@pagination_next_date = @results[limit - 1].event_datetime
@results = @results[0, limit]
end
else
@pagination_next_date = @results[-1].event_datetime if offset && @results[-1]
if @results.size > limit
@pagination_previous_date = @results[-(limit)].event_datetime
@results = @results[-(limit), limit]
end
end
else
unless @tokens.any?
@question = ''
end
@available_search_types = Redmine::Search.available_search_types.dup.push('all')
gon.global_search = {
search_term: @question,
project_scope: search_params[:scope].to_s,
available_search_types: @available_search_types.map do |search_type|
{
id: search_type,
name: OpenProject::GlobalSearch.tab_name(search_type)
}
end,
current_tab: @available_search_types.select { |search_type| search_params[search_type] }.first || 'all'
}
render layout: layout_non_or_no_menu
end
private
def quick_wp_id_redirect
scan_work_package_reference @question do |id|
redirect_to work_package_path(id: id) if WorkPackage.visible.find_by(id: id)
end
end
def find_optional_project
return true unless params[:project_id]
@project = Project.find(params[:project_id])
check_project_privacy
rescue ActiveRecord::RecordNotFound
render_404
end
def limit_results_first_page
@pagination_previous_date = @results[0].event_datetime if offset && @results[0]
if @results.size > LIMIT
@pagination_next_date = @results[LIMIT - 1].event_datetime
@results = @results[0, LIMIT]
end
end
def limit_results_subsequent_page
@pagination_next_date = @results[-1].event_datetime if offset && @results[-1]
if @results.size > LIMIT
@pagination_previous_date = @results[-(LIMIT)].event_datetime
@results = @results[-(LIMIT), LIMIT]
end
end
# extract tokens from the question
# eg. hello "bye bye" => ["hello", "bye bye"]
def scan_query_tokens(query)
query.scan(%r{((\s|^)"[\s\w]+"(\s|$)|\S+)}).map { |m| m.first.gsub(%r{(^\s*"\s*|\s*"\s*$)}, '') }
tokens = query.scan(%r{((\s|^)"[\s\w]+"(\s|$)|\S+)}).map { |m| m.first.gsub(%r{(^\s*"\s*|\s*"\s*$)}, '') }
# no more than 5 tokens to search for
tokens.slice! 5..-1 if tokens.size > 5
tokens
end
def scan_work_package_reference(query, &blk)
query.match(/\A#?(\d+)\z/) && ((blk && blk.call($1)) || true)
query.match(/\A#?(\d+)\z/) && ((blk&.call($1.to_i)) || true)
end
def search_params
@search_params ||= permitted_params.search
end
def offset
Time.at(Rational(search_params[:offset])) if search_params[:offset]
rescue TypeError
nil
end
def projects_to_search
case search_params[:scope]
when 'all'
nil
when 'current_project'
@project
else
@project ? @project.self_and_descendants.active : nil
end
end
def search_results(tokens)
results = []
results_count = Hash.new(0)
search_classes.each do |scope, klass|
r, c = klass.search(tokens,
projects_to_search,
limit: (LIMIT + 1),
offset: offset,
before: search_params[:previous].nil?)
results += r
results_count[scope] += c
end
results = sort_by_event_datetime(results)
[results, results_count]
end
def sort_by_event_datetime(results)
results.sort { |a, b| b.event_datetime <=> a.event_datetime }
end
def search_types
types = Redmine::Search.available_search_types.dup
if projects_to_search.is_a? Project
# don't search projects
types.delete('projects')
# only show what the user is allowed to view
types = types.select { |o| User.current.allowed_to?("view_#{o}".to_sym, projects_to_search) }
end
types
end
def search_classes
scope = search_types & search_params.keys
scope = if scope.empty?
search_types
elsif scope & ['work_packages'] == scope
[]
else
scope
end
scope.map { |s| [s, scope_class(s)] }.to_h
end
def scope_class(scope)
scope.singularize.camelcase.constantize
end
def provision_gon
available_search_types = Redmine::Search.available_search_types.dup.push('all')
gon.global_search = {
search_term: @question,
project_scope: search_params[:scope].to_s,
available_search_types: available_search_types.map do |search_type|
{
id: search_type,
name: OpenProject::GlobalSearch.tab_name(search_type)
}
end,
current_tab: available_search_types.select { |search_type| search_params[search_type] }.first || 'all'
}
end
end

@ -40,7 +40,7 @@ class WorkPackagesController < ApplicationController
before_action :find_optional_project,
:protect_from_unauthorized_export, only: :index
before_action :load_query, only: :index, unless: ->() { request.format.html? }
before_action :load_and_validate_query, only: :index, unless: ->() { request.format.html? }
before_action :load_work_packages, only: :index, if: ->() { request.format.atom? }
before_action :set_gon_settings
@ -143,8 +143,15 @@ class WorkPackagesController < ApplicationController
%w[atom rss] + WorkPackage::Exporter.list_formats.map(&:to_s)
end
def load_query
def load_and_validate_query
@query ||= retrieve_query
unless @query.valid?
# Ensure outputting a html response
request.format = 'html'
return render_400(message: @query.errors.full_messages.join(". "))
end
rescue ActiveRecord::RecordNotFound
render_404
end

@ -97,26 +97,6 @@ module SearchHelper
OpenProject::GlobalSearch.tab_name(t)
end
def render_results_by_type(results_by_type)
links = []
# Sorts types by results count
results_by_type.keys.sort { |a, b| results_by_type[b] <=> results_by_type[a] }.each do |t|
c = results_by_type[t]
next if c == 0
text = "#{type_label(t)} (#{c})"
target = {
controller: 'search',
project_id: (@project.identifier if @project),
action: 'index',
q: params[:q],
scope: current_scope,
t => 1
}
links << link_to(h(text), target)
end
('<ul>' + links.map { |link| content_tag('li', link) }.join(' ') + '</ul>').html_safe unless links.empty?
end
def current_scope
params[:scope] ||
('subprojects' unless @project.nil? || @project.descendants.active.empty?) ||

@ -29,27 +29,51 @@
#++
class CustomActions::Conditions::Role < CustomActions::Conditions::Base
def self.key
:role
end
def self.custom_action_scope_has_current(work_packages, user)
CustomAction
.includes(association_key)
.where(habtm_table => { key_id => roles_in_project(work_packages, user) })
end
private_class_method :custom_action_scope_has_current
def fulfilled_by?(work_package, user)
values.empty? ||
(self.class.roles_in_project(work_package, user).map(&:id) & values).any?
end
def self.roles_in_project(work_packages, user)
::Role
.joins(:members)
.where(members: { project_id: Array(work_packages).map(&:project_id).uniq, user_id: user.id })
.select(:id)
class << self
def key
:role
end
def roles_in_project(work_packages, user)
with_request_store(project_ids_of(work_packages)) do |ids|
::Role
.joins(:members)
.where(members: { project_id: ids, user_id: user.id })
.select(:id)
end
end
private
def custom_action_scope_has_current(work_packages, user)
CustomAction
.includes(association_key)
.where(habtm_table => { key_id => roles_in_project(work_packages, user) })
end
def project_ids_of(work_packages)
# Using this if/else instead of Array(work_packages)
# to avoid "delegator does not forward private method #to_ary" warnings
# for WorkPackageEagerLoadingWrapper
if work_packages.respond_to?(:map)
work_packages.map(&:project_id).uniq
else
[work_packages.project_id]
end
end
def with_request_store(project_ids)
RequestStore.store[:custom_actions_role] ||= Hash.new do |hash, ids|
hash[ids] = yield ids
end
RequestStore.store[:custom_actions_role][project_ids]
end
end
private

@ -508,8 +508,7 @@ class MailHandler < ActionMailer::Base
def update_work_package(work_package)
attributes = collect_wp_attributes_from_email_on_update(work_package)
attributes.merge!(attachment_ids: create_attachments_from_mail.map(&:id))
attributes[:attachment_ids] = work_package.attachment_ids + create_attachments_from_mail.map(&:id)
service_call = WorkPackages::UpdateService
.new(user: user,

@ -30,6 +30,7 @@
class Queries::WorkPackages::Filter::AttachmentBaseFilter < Queries::WorkPackages::Filter::WorkPackageFilter
include Queries::WorkPackages::Filter::FilterOnTsvMixin
include Queries::WorkPackages::Filter::TextFilterOnJoinMixin
def type
:text
@ -39,12 +40,26 @@ class Queries::WorkPackages::Filter::AttachmentBaseFilter < Queries::WorkPackage
EnterpriseToken.allows_to?(:attachment_filters) && OpenProject::Database.allows_tsv?
end
def includes
:attachments
def where
Queries::Operators::All.sql_for_field(values, join_table_alias, 'id')
end
def where
OpenProject::FullTextSearch.tsv_where(Attachment.table_name,
private
def join_table
Attachment.table_name
end
def join_condition
<<-SQL
#{join_table_alias}.container_id = #{WorkPackage.table_name}.id
AND #{join_table_alias}.container_type = '#{WorkPackage.name}'
AND #{tsv_condition}
SQL
end
def tsv_condition
OpenProject::FullTextSearch.tsv_where(join_table_alias,
search_column,
values.first,
concatenation: concatenation,

@ -29,15 +29,27 @@
#++
class Queries::WorkPackages::Filter::CommentFilter < Queries::WorkPackages::Filter::WorkPackageFilter
include Queries::WorkPackages::Filter::TextFilterOnJoinMixin
def type
:text
end
def includes
%i{journals}
def join_condition
<<-SQL
#{join_table_alias}.journable_id = #{WorkPackage.table_name}.id
AND #{join_table_alias}.journable_type = '#{WorkPackage.name}'
AND #{notes_condition}
SQL
end
private
def join_table
Journal.table_name
end
def where
operator_strategy.sql_for_field(values, Journal.table_name, 'notes')
def notes_condition
Queries::Operators::Contains.sql_for_field(values, join_table_alias, 'notes')
end
end

@ -41,10 +41,6 @@ class Queries::WorkPackages::Filter::ManualSortFilter <
true
end
def joins
ordered_work_packages_join(query)
end
def type
:empty_value
end

@ -45,6 +45,10 @@ module Queries::WorkPackages::Filter::OrFilterForWpMixin
@filters.keep_if(&:validate)
end
def joins
filters.map(&:joins).flatten.compact
end
def includes
filters.map(&:includes).flatten.uniq.reject(&:blank?)
end

@ -30,9 +30,9 @@
class Queries::WorkPackages::Filter::SearchFilter <
Queries::WorkPackages::Filter::WorkPackageFilter
include Queries::WorkPackages::Filter::OrFilterForWpMixin
include Queries::WorkPackages::Filter::FilterOnTsvMixin
CONTAINS_OPERATOR = '~'.freeze
CE_FILTERS = [
@ -84,7 +84,13 @@ class Queries::WorkPackages::Filter::SearchFilter <
def filter_configurations
list = CE_FILTERS
list += EE_TSV_FILTERS if EnterpriseToken.allows_to?(:attachment_filters) && OpenProject::Database.allows_tsv?
list += EE_TSV_FILTERS if attachment_filters_allowed?
list
end
private
def attachment_filters_allowed?
EnterpriseToken.allows_to?(:attachment_filters) && OpenProject::Database.allows_tsv?
end
end

@ -1,3 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2018 the OpenProject Foundation (OPF)
@ -26,21 +28,36 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
module Queries::WorkPackages::Filter::TextFilterOnJoinMixin
def where
case operator
when '~'
Queries::Operators::All.sql_for_field(values, join_table_alias, 'id')
when '!~'
Queries::Operators::None.sql_for_field(values, join_table_alias, 'id')
else
raise 'Unsupported operator'
end
end
def joins
<<-SQL
LEFT OUTER JOIN #{join_table} #{join_table_alias}
ON #{join_condition}
SQL
end
private
def join_table
raise NotImplementedError
end
describe 'search/index', type: :view do
let(:project) { FactoryBot.create :project }
let(:subproject) { FactoryBot.create(:project, parent: project).tap { |_| project.reload } }
let(:work_package) { FactoryBot.create :work_package, project: project }
def join_condition
raise NotImplementedError
end
before do
assign :project, project
assign :subproject, subproject
assign :object_types, ['work_packages']
assign :scope, ['work_packages', 'changesets']
assign :results, [work_package]
assign :results_by_type, 'work_packages' => 1
assign :question, 'foo'
assign :tokens, ['bar']
def join_table_alias
"#{self.class.key}_#{join_table}"
end
end

@ -307,7 +307,12 @@ class Query < ActiveRecord::Base
end
def sort_criteria
read_attribute(:sort_criteria) || []
(read_attribute(:sort_criteria) || []).tap do |criteria|
criteria.map! do |attr, direction|
attr = 'id' if attr == 'parent'
[attr, direction]
end
end
end
def sort_criteria_key(arg)

@ -44,6 +44,7 @@ class ::Query::Results
# Returns the work package count
def work_package_count
WorkPackage.visible
.joins(all_filter_joins)
.includes(:status, :project)
.where(query.statement)
.references(:statuses, :projects)
@ -148,7 +149,7 @@ class ::Query::Results
end
def all_joins
query.sort_criteria_columns.map { |column, _direction| column.sortable_join_statement(query) }.compact
sort_criteria_joins + all_filter_joins
end
def includes_for_columns(column_names)
@ -166,6 +167,7 @@ class ::Query::Results
.group(query.group_by_statement)
.visible
.includes(all_includes)
.joins(all_filter_joins)
.references(:statuses, :projects)
.where(query.statement)
.count
@ -230,6 +232,13 @@ class ::Query::Results
clean_symbol_list(columns)
end
def sort_criteria_joins
query
.sort_criteria_columns
.map { |column, _direction| column.sortable_join_statement(query) }
.compact
end
def sort_criteria_array
criteria = SortHelper::SortCriteria.new
criteria.available_criteria = aliased_sorting_by_column_name
@ -313,6 +322,10 @@ class ::Query::Results
query.filters.map(&:includes)
end
def all_filter_joins
query.filters.map(&:joins).flatten.compact
end
def clean_symbol_list(list)
list.flatten.compact.uniq.map(&:to_sym)
end

@ -49,7 +49,7 @@ See docs/COPYRIGHT.rdoc for more details.
<% if show_homescreen_links? && @homescreen[:links].any? %>
<section class="homescreen--links">
<% @homescreen[:links].each do |link| %>
<% title = I18n.t(link[:label], scope: 'homescreen.links') %>
<% title = I18n.t(link[:label], scope: 'homescreen.links', default: I18n.t(link[:label])) %>
<a class="homescreen--links--item" href="<%= link[:url] %>" target="_blank" title="<%= title %>">
<%= op_icon(link[:icon]) %>
<%= title %>

@ -29,6 +29,6 @@ See docs/COPYRIGHT.rdoc for more details.
<div class="form--field -required">
<%= form.text_field :name, required: true,
autofocus: form.object.name.empty?,
autofocus: form.object&.name&.empty?,
container_class: '-wide' %>
</div>

@ -31,29 +31,35 @@ See docs/COPYRIGHT.rdoc for more details.
<%= call_hook :search_index_head %>
<%= include_gon(nonce: content_security_policy_script_nonce) %>
<% end %>
<global-search-title></global-search-title>
<% current_module = params.slice(*@object_types).keys.first.to_s %>
<global-search-title></global-search-title>
<global-search-tabs></global-search-tabs>
<% if params[:work_packages].present? %>
<global-search-work-packages-entry></global-search-work-packages-entry>
<% else %>
<h3><%= l(:label_result_plural) %> (<%= @results_by_type&.values&.sum || '0' %>)</h3>
<% if @results %>
<h3><%= l(:label_result_plural) %> (<%= @results_count&.values&.sum || 0 %>)</h3>
<% if @results.present? %>
<%= render partial: 'pagination', locals: {pagination_previous_date: @pagination_previous_date, pagination_next_date: @pagination_next_date } %>
<dl id="search-results">
<% @results.each do |e| %>
<dt class="<%= e.event_type %>">
<% event_type = e.event_type == 'meeting' ? 'meetings' : e.event_type %>
<% event_type = e.event_type == 'cost_object' ? 'budget' : event_type %>
<% event_type = e.event_type == 'reply' ? 'forums' : event_type %>
<%= icon_wrapper("icon-context icon-#{event_type}", e.event_name) %>
<% if e.project != @project %>
<span class="project"><%= e.project %></span>
<span> - </span>
<% end %>
<%= link_to highlight_tokens(truncate(e.event_title, escape: false, length: 255), @tokens), with_notes_anchor(e, @tokens) %>
</dt>
<dd>
@ -65,6 +71,7 @@ See docs/COPYRIGHT.rdoc for more details.
</dd>
<% end %>
</dl>
<% end %>
<% end %>

@ -41,15 +41,5 @@ See docs/COPYRIGHT.rdoc for more details.
<div class="form--field-instructions"><%= t('incoming_mails.ignore_filenames') %></div>
</div>
</section>
<section class="form--section">
<div class="form--field">
<%= setting_check_box :mail_handler_api_enabled %>
<% csp_onclick("jQuery('#settings_mail_handler_api_key').prop('disabled', !this.checked );",
'#settings_mail_handler_api_enabled') %>
<div class="form--field-instructions">
<%= t(:setting_mail_handler_api_description) %>
</div>
</div>
</section>
<%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %>
<% end %>

@ -67,28 +67,36 @@ OpenProject::Static::Homescreen.manage :blocks do |blocks|
end
OpenProject::Static::Homescreen.manage :links do |links|
static_links = OpenProject::Static::Links.links
link_hash = OpenProject::Static::Links.links
links.push(
{
label: :user_guides,
icon: 'icon-context icon-rename',
url: static_links[:user_guides][:href]
url: link_hash[:user_guides][:href]
},
{
label: :glossary,
icon: 'icon-context icon-glossar',
url: static_links[:glossary][:href]
url: link_hash[:glossary][:href]
},
{
label: :shortcuts,
icon: 'icon-context icon-shortcuts',
url: static_links[:shortcuts][:href]
url: link_hash[:shortcuts][:href]
},
{
label: :forums,
icon: 'icon-context icon-forums',
url: static_links[:forums][:href]
url: link_hash[:forums][:href]
}
)
if impressum_link = link_hash[:impressum]
links.push({
label: impressum_link[:label],
url: impressum_link[:href],
icon: 'icon-context icon-info1'
})
end
end

@ -1340,6 +1340,7 @@ en:
label_hierarchy_leaf: "Hierarchy leaf"
label_home: "Home"
label_subject_or_id: "Subject or ID"
label_impressum: "Impressum"
label_in: "in"
label_in_less_than: "in less than"
label_in_more_than: "in more than"
@ -1473,6 +1474,7 @@ en:
label_previous_week: "Previous week"
label_principal_invite_via_email: " or invite new users via email"
label_principal_search: "Add existing users or groups"
label_privacy_policy: "Data privacy and security policy"
label_product_version: "Product version"
label_professional_support: "Professional support"
label_profile: "Profile"
@ -2134,8 +2136,6 @@ en:
setting_log_requesting_user: "Log user login, name, and mail address for all requests"
setting_login_required: "Authentication required"
setting_mail_from: "Emission email address"
setting_mail_handler_api_enabled: "Enable incoming email web service"
setting_mail_handler_api_description: "The email web handler enables OpenProject to receive emails containing specific commands as an instrumentation mechanism (e.g., to create and update work packages)."
setting_mail_handler_api_key: "API key"
setting_mail_handler_body_delimiters: "Truncate emails after one of these lines"
setting_mail_handler_body_delimiter_regex: "Truncate emails matching this regex"

@ -252,8 +252,6 @@ mail_handler_ignore_filenames:
default: 'signature.asc'
mail_handler_body_delimiter_regex:
default: ''
mail_handler_api_enabled:
default: 0
mail_handler_api_key:
default:
mail_suffix_separators:

@ -1,3 +0,0 @@
web: ./docker/web
worker: ./docker/worker
cron: ./docker/cron

@ -0,0 +1,3 @@
#!/bin/bash
set -e
exec bundle exec rails c

@ -1,12 +1,52 @@
#!/bin/bash
set -e
set -o pipefail
# handle legacy configs
if [ -d "$PGDATA_LEGACY" ]; then
echo "WARN: You are using a legacy volume path for your postgres data. You should mount your postgres volumes at $PGDATA instead of $PGDATA_LEGACY."
if [ "$(find "$PGDATA" -type f | wc -l)" = "0" ]; then
echo "INFO: $PGDATA is empty, so $PGDATA will be symlinked to $PGDATA_LEGACY as a temporary measure."
sed -i "s|$PGDATA|$PGDATA_LEGACY|" /etc/postgresql/9.6/main/postgresql.conf
export PGDATA="$PGDATA_LEGACY"
else
echo "ERROR: $PGDATA contains files, so we will not attempt to symlink $PGDATA to $PGDATA_LEGACY. Please fix your docker configuration."
exit 2
fi
fi
if [ -d "$APP_DATA_PATH_LEGACY" ]; then
echo "WARN: You are using a legacy volume path for your openproject data. You should mount your openproject volume at $APP_DATA_PATH instead of $APP_DATA_PATH_LEGACY."
if [ "$(find "$APP_DATA_PATH" -type f | wc -l)" = "0" ]; then
echo "INFO: $APP_DATA_PATH is empty, so $APP_DATA_PATH will be symlinked to $APP_DATA_PATH_LEGACY as a temporary measure."
# also set ATTACHMENTS_STORAGE_PATH back to its legacy value in case it hasn't been changed
if [ "$ATTACHMENTS_STORAGE_PATH" = "$APP_DATA_PATH/files" ]; then
export ATTACHMENTS_STORAGE_PATH="$APP_DATA_PATH_LEGACY/files"
fi
export APP_DATA_PATH="$APP_DATA_PATH_LEGACY"
else
echo "ERROR: $APP_DATA_PATH contains files, so we will not attempt to symlink $APP_DATA_PATH to $APP_DATA_PATH_LEGACY. Please fix your docker configuration."
exit 2
fi
fi
if [ "$(id -u)" = '0' ]; then
chown -R $APP_USER:$APP_USER $APP_PATH $APP_DATA /usr/local
sync
exec $APP_PATH/docker/gosu $APP_USER "$BASH_SOURCE" "$@"
mkdir -p $APP_DATA_PATH/{files,git,svn}
chown -R $APP_USER:$APP_USER $APP_DATA_PATH
if [ ! -z "$ATTACHMENTS_STORAGE_PATH" ]; then
mkdir -p "$ATTACHMENTS_STORAGE_PATH"
chown -R "$APP_USER:$APP_USER" "$ATTACHMENTS_STORAGE_PATH"
fi
mkdir -p "$APP_PATH/log" "$APP_PATH/tmp/pids" "$APP_PATH/files"
chown "$APP_USER:$APP_USER" "$APP_PATH"
chown -R "$APP_USER:$APP_USER" "$APP_PATH/log" "$APP_PATH/tmp" "$APP_PATH/files" "$APP_PATH/public"
if [ "$1" = "./docker/supervisord" ]; then
exec "$@"
else
exec $APP_PATH/docker/gosu $APP_USER "$BASH_SOURCE" "$@"
fi
fi
mkdir -p "$ATTACHMENTS_STORAGE_PATH"
chown -R "$(id -u)" "$ATTACHMENTS_STORAGE_PATH" 2>/dev/null || :
exec "$@"

@ -1,212 +0,0 @@
##############################################################
# Phusion Passenger Standalone uses a template file to
# generate an Nginx configuration file. The original template
# file can be found by running the following command:
#
# ls $(passenger-config about resourcesdir)/templates/standalone/config.erb
#
# You can create a copy of this template file and customize it
# to your liking. Just make sure you tell Phusion Passenger Standalone
# to use your template file by passing the --nginx-config-template
# parameter.
#
# *** NOTE ***
# If you customize the template file, make sure you keep an eye
# on the original template file and merge any changes.
# New Phusion Passenger features may require changes to the template
# file.
##############################################################
master_process on;
worker_processes 1;
daemon on;
error_log '<%= @options[:log_file] %>' <% if @options[:log_level] >= LVL_DEBUG %>info<% end %>;
pid '<%= @options[:pid_file] %>';
<% if Process.euid == 0 %>
<% if @options[:user] %>
<%# Run workers as the given user. The master process will always run as root and will be able to bind to any port. %>
user <%= @options[:user] %> <%= default_group_for(@options[:user]) %>;
<% else %>
<%# Prevent running Nginx workers as nobody. %>
user <%= current_user %> <%= default_group_for(current_user) %>;
<% end %>
<% end %>
events {
worker_connections 1024;
}
http {
log_format debug '[$time_local] $msec "$request" $status conn=$connection sent=$bytes_sent body_sent=$body_bytes_sent';
include '<%= PhusionPassenger.resources_dir %>/mime.types';
<% if @options[:ruby] %>
passenger_ruby <%= @options[:ruby] %>;
<% else %>
passenger_ruby <%= PlatformInfo.ruby_command %>;
<% end %>
<% if @options[:nodejs] %>
passenger_nodejs <%= @options[:nodejs] %>;
<% end %>
<% if @options[:python] %>
passenger_python <%= @options[:python] %>;
<% end %>
passenger_root '<%= PhusionPassenger.install_spec %>';
passenger_abort_on_startup_error on;
passenger_ctl cleanup_pidfiles <%= serialize_strset("#{@working_dir}/temp_dir_toucher.pid") %>;
passenger_ctl integration_mode standalone;
passenger_ctl standalone_engine nginx;
passenger_user_switching off;
<%= nginx_option :passenger_log_level, :log_level %>
<%= nginx_option :passenger_max_pool_size, :max_pool_size %>
<%= nginx_option :passenger_min_instances, :min_instances %>
<%= nginx_option :passenger_pool_idle_time, :pool_idle_time %>
<%= nginx_option :passenger_max_preloader_idle_time, :max_preloader_idle_time %>
<%= nginx_option :passenger_turbocaching, :turbocaching %>
<% if @options[:user] %>
passenger_user <%= @options[:user] %>;
passenger_default_user <%= @options[:user] %>;
passenger_analytics_log_user <%= @options[:user] %>;
<% else %>
passenger_user <%= current_user %>;
passenger_default_user <%= current_user %>;
passenger_analytics_log_user <%= current_user %>;
<% end %>
<% if @options[:instance_registry_dir] %>passenger_instance_registry_dir '<%= @options[:instance_registry_dir] %>';<% end %>
<% if @options[:data_buffer_dir] %>passenger_data_buffer_dir '<%= @options[:data_buffer_dir] %>';<% end %>
<% if @options[:rolling_restarts] %>passenger_rolling_restarts on;<% end %>
<% if @options[:resist_deployment_errors] %>passenger_resist_deployment_errors on;<% end %>
<% if !@options[:load_shell_envvars] %>passenger_load_shell_envvars off;<% end %>
<% if !@options[:friendly_error_pages].nil? -%>
passenger_friendly_error_pages <%= boolean_config_value(@options[:friendly_error_pages]) %>;
<% end %>
<% if @options[:union_station_gateway_address] %>
union_station_gateway_address <%= @options[:union_station_gateway_address] %>;
<% end %>
<% if @options[:union_station_gateway_port] %>
union_station_gateway_port <%= @options[:union_station_gateway_port] %>;
<% end %>
<% if @options[:union_station_gateway_cert] %>
union_station_gateway_cert -;
<% end %>
<% @options[:ctls].each do |ctl| %>
passenger_ctl '<%= ctl.split("=", 2)[0] %>' '<%= ctl.split("=", 2)[1] %>';
<% end %>
default_type application/octet-stream;
types_hash_max_size 2048;
server_names_hash_bucket_size 64;
client_max_body_size 20m;
access_log off;
keepalive_timeout 60;
underscores_in_headers on;
gzip on;
gzip_comp_level 3;
gzip_min_length 150;
gzip_proxied any;
gzip_types text/plain text/css text/json text/javascript
application/javascript application/x-javascript application/json
application/rss+xml application/vnd.ms-fontobject application/x-font-ttf
application/xml font/opentype image/svg+xml text/xml;
<% if @app_finder.multi_mode? %>
# Default server entry for mass deployment mode.
server {
<% if @options[:ssl] %>
<% if @options[:ssl_port] %>
listen <%= nginx_listen_address %>;
listen <%= nginx_listen_address_with_ssl_port %> ssl;
<% else %>
listen <%= nginx_listen_address %> ssl;
<% end %>
<% else %>
listen <%= nginx_listen_address %>;
<% end %>
root '<%= PhusionPassenger.resources_dir %>/standalone_default_root';
}
<% end %>
<% for app in @apps %>
server {
<% if app[:ssl] %>
<% if app[:ssl_port] %>
listen <%= nginx_listen_address(app) %>;
listen <%= nginx_listen_address_with_ssl_port(app) %> ssl;
<% else %>
listen <%= nginx_listen_address(app) %> ssl;
<% end %>
<% else %>
listen <%= nginx_listen_address(app) %>;
<% end %>
server_name <%= app[:server_names].join(' ') %>;
<% if app[:static_files_dir] %>
root '<%= app[:static_files_dir] %>';
<% else %>
root '<%= app[:root] %>/public';
<% end %>
passenger_app_root '<%= app[:root] %>';
passenger_enabled on;
passenger_app_env <%= app[:environment] %>;
passenger_spawn_method <%= app[:spawn_method] %>;
<% if app[:app_type] %>passenger_app_type <%= app[:app_type] %>;<% end %>
<% if app[:startup_file] %>passenger_startup_file <%= app[:startup_file] %>;<% end %>
<% if app[:concurrency_model] && app[:concurrency_model] != DEFAULT_CONCURRENCY_MODEL %>passenger_concurrency_model <%= app[:concurrency_model] %>;<% end %>
<% if app[:thread_count] && app[:thread_count] != DEFAULT_APP_THREAD_COUNT %>passenger_thread_count <%= app[:thread_count] %>;<% end %>
<% if app[:min_instances] %>passenger_min_instances <%= app[:min_instances] %>;<% end %>
<% if app[:restart_dir] %>passenger_restart_dir '<%= app[:restart_dir] %>';<% end %>
<% if app[:sticky_sessions] %>passenger_sticky_sessions on;<% end %>
<% if app[:sticky_sessions_cookie_name] %>passenger_sticky_sessions_cookie_name '<%= app[:sticky_sessions_cookie_name] %>';<% end %>
<% if app[:vary_turbocache_by_cookie] %>passenger_vary_turbocache_by_cookie '<%= app[:vary_turbocache_by_cookie] %>';<% end %>
<% if app[:union_station_key] %>
union_station_support on;
union_station_key <%= app[:union_station_key] %>;
<% end %>
<% if app[:ssl] %>
ssl_certificate <%= app[:ssl_certificate] %>;
ssl_certificate_key <%= app[:ssl_certificate_key] %>;
<% end %>
<% if @options[:meteor_app_settings] %>
passenger_meteor_app_settings <%= @options[:meteor_app_settings] %>;
<% end %>
<% app[:envvars].each_pair do |name, value| %>
passenger_env_var '<%= name %>' '<%= value %>';
<% end %>
# Rails asset pipeline support.
location ~ "^/assets/.+-([0-9a-f]{32}|[0-9a-f]{64})\..+" {
error_page 490 = @static_asset;
error_page 491 = @dynamic_request;
recursive_error_pages on;
if (-f $request_filename) {
return 490;
}
if (!-f $request_filename) {
return 491;
}
}
location @static_asset {
gzip_static on;
expires max;
add_header Cache-Control public;
add_header Access-Control-Allow-Origin "*";
add_header ETag "";
}
location @dynamic_request {
passenger_enabled on;
}
<% (ENV['NGINX_ADDITIONAL_SERVER_RULES'] || "").split(";").each do |rule| %>
<%= rule.chomp(";") %>;
<% end %>
}
passenger_pre_start <%= listen_url(app) %>;
<% end %>
}

@ -0,0 +1,18 @@
#!/bin/bash
set -e
pushd "${APP_PATH}/frontend"
# Installing frontend dependencies
RAILS_ENV=production npm install
popd
# Bundle assets
DATABASE_URL='nulldb://nohost' RAILS_ENV=production bundle exec rake assets:precompile
# Remove node_modules and entire frontend
rm -rf "$APP_PATH/node_modules/" "$APP_PATH/frontend/node_modules/"
# Clean cache in root
rm -rf /root/.npm

@ -1,5 +1,5 @@
#!/bin/bash
set -e
[ -f /etc/apache2/sites-enabled/openproject.conf ] || erb -r time /usr/src/app/docker/proxy.conf.erb > /etc/apache2/sites-enabled/openproject.conf
[ -f /etc/apache2/sites-enabled/openproject.conf ] || erb -r time $APP_PATH/docker/proxy.conf.erb > /etc/apache2/sites-enabled/openproject.conf
exec /usr/sbin/apache2ctl -DFOREGROUND

@ -1,6 +1,6 @@
<VirtualHost *:80>
ServerName <%= ENV.fetch('SERVER_NAME') { "_default_" } %>
DocumentRoot /usr/src/app/public
DocumentRoot <%= ENV.fetch('APP_PATH') %>/public
ProxyRequests off

@ -3,32 +3,30 @@
set -e
set -o pipefail
PGDATA=${PGDATA:=/var/lib/postgresql/9.6/main}
indent() {
sed -u 's/^/ /'
}
echo "-----> Starting the all-in-one OpenProject setup at $BASH_SOURCE..."
if [ "$PGDATA" == "" ]; then
echo "No PGDATA environment variable defined. Aborting." | indent
exit 2
fi
PGUSER=${PGUSER:=postgres}
PGPASSWORD=${PGPASSWORD:=postgres}
PG_STARTUP_WAIT_TIME=${PG_STARTUP_WAIT_TIME:=10}
SUPERVISORD_LOG_LEVEL=${SUPERVISORD_LOG_LEVEL:=info}
PGBIN="/usr/lib/postgresql/9.6/bin"
if [ ! -z "$ATTACHMENTS_STORAGE_PATH" ]; then
mkdir -p "$ATTACHMENTS_STORAGE_PATH"
chown -R app:app "$ATTACHMENTS_STORAGE_PATH"
fi
if [ "$(id -u)" = '0' ]; then
echo "-----> Ensure $APP_PATH is owned by $APP_USER"
mkdir -p "$APP_PATH/log" "$APP_PATH/tmp" "$APP_PATH/files"
chown $APP_USER:$APP_USER "$APP_PATH"
chown -R $APP_USER:$APP_USER "$APP_PATH/log" "$APP_PATH/tmp" "$APP_PATH/files" "$APP_PATH/public"
fi
dbhost=$(ruby -ruri -e 'puts URI(ENV.fetch("DATABASE_URL")).host')
pwfile=$(mktemp)
echo "$PGPASSWORD" > $pwfile
chown postgres $pwfile
PLUGIN_GEMFILE_TMP=$(mktemp)
PLUGIN_GEMFILE=/usr/src/app/Gemfile.local
PLUGIN_GEMFILE=$APP_PATH/Gemfile.local
if [ "$PLUGIN_GEMFILE_URL" != "" ]; then
echo "Fetching custom gemfile from ${PLUGIN_GEMFILE_URL}..."
@ -37,37 +35,43 @@ if [ "$PLUGIN_GEMFILE_URL" != "" ]; then
# set custom plugin gemfile if file is readable and non-empty
if [ -s "$PLUGIN_GEMFILE_TMP" ]; then
mv "$PLUGIN_GEMFILE_TMP" "$PLUGIN_GEMFILE"
chown app.app "$PLUGIN_GEMFILE"
chown $APP_USER:$APP_USER "$PLUGIN_GEMFILE"
fi
fi
indent() {
sed -u 's/^/ /'
}
install_plugins() {
pushd /usr/src/app
pushd $APP_PATH >/dev/null
if [ -s "$PLUGIN_GEMFILE" ]; then
echo "Installing plugins..."
bundle install
echo "Installing frontend dependencies..."
pushd $APP_PATH/frontend >/dev/null
if [ "$(id -u)" = '0' ]; then
su - $APP_USER -c "cd $APP_PATH/frontend && npm install"
else
npm install
fi
popd >/dev/null
echo "Precompiling new assets..."
bundle exec rake assets:precompile
echo "Plugins installed"
fi
popd
popd >/dev/null
}
migrate() {
wait_for_postgres
pushd /usr/src/app
pushd $APP_PATH >/dev/null
/etc/init.d/memcached start
bundle exec rake db:migrate db:seed db:structure:dump
/etc/init.d/memcached stop
chown app:app db/structure.sql
popd
chown "$APP_USER.$APP_USER" db/structure.sql
popd >/dev/null
}
wait_for_postgres() {
@ -108,5 +112,4 @@ echo "-----> Database setup finished."
echo " On first installation, the default admin credentials are login: admin, password: admin"
echo "-----> Launching supervisord..."
exec /usr/bin/supervisord -e ${SUPERVISORD_LOG_LEVEL}
exec /usr/bin/supervisord -c $APP_PATH/docker/supervisord.conf -e ${SUPERVISORD_LOG_LEVEL}

@ -4,44 +4,60 @@ nodaemon=true
[program:web]
priority=4
user=app
directory=/usr/src/app
environment=HOME="/home/%(ENV_APP_USER)s",USER="%(ENV_APP_USER)s"
directory=%(ENV_APP_PATH)s
command=./docker/web
autorestart=true
stderr_logfile = /var/log/supervisor/%(program_name)s-stderr.log
stdout_logfile = /var/log/supervisor/%(program_name)s-stdout.log
stderr_logfile = /dev/stderr
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile_maxbytes = 0
[program:worker]
priority=5
user=app
directory=/usr/src/app
environment=HOME="/home/%(ENV_APP_USER)s",USER="%(ENV_APP_USER)s"
directory=%(ENV_APP_PATH)s
command=./docker/worker
startretries=10
autorestart=true
stderr_logfile = /var/log/supervisor/%(program_name)s-stderr.log
stdout_logfile = /var/log/supervisor/%(program_name)s-stdout.log
stderr_logfile = /dev/stderr
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile_maxbytes = 0
[program:memcached]
priority=100
user=app
environment=HOME="/home/%(ENV_APP_USER)s",USER="%(ENV_APP_USER)s"
command=/usr/bin/memcached
autorestart=true
stderr_logfile = /var/log/supervisor/%(program_name)s-stderr.log
stdout_logfile = /var/log/supervisor/%(program_name)s-stdout.log
stderr_logfile = /dev/stderr
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile_maxbytes = 0
[program:cron]
priority=100
user=app
directory=/usr/src/app
environment=HOME="/home/%(ENV_APP_USER)s",USER="%(ENV_APP_USER)s"
directory=%(ENV_APP_PATH)s
command=./docker/cron
autostart=false
autorestart=true
stderr_logfile = /var/log/supervisor/%(program_name)s-stderr.log
stdout_logfile = /var/log/supervisor/%(program_name)s-stdout.log
stderr_logfile = /dev/stderr
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile_maxbytes = 0
[program:apache2]
priority=2
directory=/usr/src/app
directory=%(ENV_APP_PATH)s
command=./docker/proxy
stderr_logfile = /var/log/supervisor/%(program_name)s-stderr.log
stdout_logfile = /var/log/supervisor/%(program_name)s-stdout.log
stderr_logfile = /dev/stderr
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile_maxbytes = 0
[program:postfix]
priority=100
@ -49,13 +65,18 @@ directory=/etc/postfix
command=/usr/sbin/postfix -c /etc/postfix start
startsecs=0
autorestart=false
stderr_logfile = /var/log/supervisor/%(program_name)s-stderr.log
stdout_logfile = /var/log/supervisor/%(program_name)s-stdout.log
stderr_logfile = /dev/stderr
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile_maxbytes = 0
[program:postgres]
user=postgres
priority=1
command=/usr/lib/postgresql/9.6/bin/postgres -D /var/lib/postgresql/9.6/main -c config_file=/etc/postgresql/9.6/main/postgresql.conf
command=/usr/lib/postgresql/9.6/bin/postgres -D %(ENV_PGDATA)s -c config_file=/etc/postgresql/9.6/main/postgresql.conf
autorestart=true
stderr_logfile = /var/log/supervisor/%(program_name)s-stderr.log
stdout_logfile = /var/log/supervisor/%(program_name)s-stdout.log
stderr_logfile = /dev/stderr
stdout_logfile = /dev/stdout
stdout_logfile_maxbytes = 0
stderr_logfile_maxbytes = 0

@ -1,161 +0,0 @@
#!/usr/bin/env bash
# Use this script to test if a given TCP host/port are available
cmdname=$(basename $0)
echoerr() { if [[ $QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }
usage()
{
cat << USAGE >&2
Usage:
$cmdname host:port [-s] [-t timeout] [-- command args]
-h HOST | --host=HOST Host or IP under test
-p PORT | --port=PORT TCP port under test
Alternatively, you specify the host and port as host:port
-s | --strict Only execute subcommand if the test succeeds
-q | --quiet Don't output any status messages
-t TIMEOUT | --timeout=TIMEOUT
Timeout in seconds, zero for no timeout
-- COMMAND ARGS Execute command with args after the test finishes
USAGE
exit 1
}
wait_for()
{
if [[ $TIMEOUT -gt 0 ]]; then
echoerr "$cmdname: waiting $TIMEOUT seconds for $HOST:$PORT"
else
echoerr "$cmdname: waiting for $HOST:$PORT without a timeout"
fi
start_ts=$(date +%s)
while :
do
(echo > /dev/tcp/$HOST/$PORT) >/dev/null 2>&1
result=$?
if [[ $result -eq 0 ]]; then
end_ts=$(date +%s)
echoerr "$cmdname: $HOST:$PORT is available after $((end_ts - start_ts)) seconds"
break
fi
sleep 1
done
return $result
}
wait_for_wrapper()
{
# In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
if [[ $QUIET -eq 1 ]]; then
timeout $TIMEOUT $0 --quiet --child --host=$HOST --port=$PORT --timeout=$TIMEOUT &
else
timeout $TIMEOUT $0 --child --host=$HOST --port=$PORT --timeout=$TIMEOUT &
fi
PID=$!
trap "kill -INT -$PID" INT
wait $PID
RESULT=$?
if [[ $RESULT -ne 0 ]]; then
echoerr "$cmdname: timeout occurred after waiting $TIMEOUT seconds for $HOST:$PORT"
fi
return $RESULT
}
# process arguments
while [[ $# -gt 0 ]]
do
case "$1" in
*:* )
hostport=(${1//:/ })
HOST=${hostport[0]}
PORT=${hostport[1]}
shift 1
;;
--child)
CHILD=1
shift 1
;;
-q | --quiet)
QUIET=1
shift 1
;;
-s | --strict)
STRICT=1
shift 1
;;
-h)
HOST="$2"
if [[ $HOST == "" ]]; then break; fi
shift 2
;;
--host=*)
HOST="${1#*=}"
shift 1
;;
-p)
PORT="$2"
if [[ $PORT == "" ]]; then break; fi
shift 2
;;
--port=*)
PORT="${1#*=}"
shift 1
;;
-t)
TIMEOUT="$2"
if [[ $TIMEOUT == "" ]]; then break; fi
shift 2
;;
--timeout=*)
TIMEOUT="${1#*=}"
shift 1
;;
--)
shift
CLI="$@"
break
;;
--help)
usage
;;
*)
echoerr "Unknown argument: $1"
usage
;;
esac
done
if [[ "$HOST" == "" || "$PORT" == "" ]]; then
echoerr "Error: you need to provide a host and port to test."
usage
fi
TIMEOUT=${TIMEOUT:-15}
STRICT=${STRICT:-0}
CHILD=${CHILD:-0}
QUIET=${QUIET:-0}
if [[ $CHILD -gt 0 ]]; then
wait_for
RESULT=$?
exit $RESULT
else
if [[ $TIMEOUT -gt 0 ]]; then
wait_for_wrapper
RESULT=$?
else
wait_for
RESULT=$?
fi
fi
if [[ $CLI != "" ]]; then
if [[ $RESULT -ne 0 && $STRICT -eq 1 ]]; then
echoerr "$cmdname: strict mode, refusing to execute subprocess"
exit $RESULT
fi
exec $CLI
else
exit $RESULT
fi

@ -19,5 +19,4 @@ exec bundle exec passenger start \
--min-instances "$MIN_INSTANCES" \
--max-pool-size "$MAX_INSTANCES" \
--spawn-method "$SPAWN_METHOD" \
--nginx-config-template "docker/nginx.conf.erb" \
--max-preloader-idle-time 0

@ -135,6 +135,14 @@ If this option is active /login will lead directly to the configured omniauth pr
Note that this does not stop a user from manually navigating to any other
omniauth provider if additional ones are configured.
### Gravatar images
OpenProject uses gravatar images with a `404` fallback by default to render an internal, initials-based avatar.
You can override this behavior by setting `gravatar_fallback_image` to a different value.
For supported values, please see https://en.gravatar.com/site/implement/images/
### attachments storage
*default: file*
@ -192,6 +200,10 @@ for the migration.
You can override the default help menu of OpenProject by specifying a `force_help_link` option to
the configuration. This value is used for the href of the help link, and the default dropdown is removed.
### Setting an impressum (legal notice) link
You can set a impressum link for your OpenProject instance by setting `impressum_link` to an absolute URL.
### hidden menu items
*default: {}*

@ -11,16 +11,6 @@
</button>
</li>
<li *ngIf="isActive()">
<button id="work-packages-timeline-zoom-in-button"
class="button timeline-toolbar--button toolbar-icon"
[attr.title]="text.zoomIn"
[disabled]="currentZoom == minZoomLevel"
(click)="updateZoomWithDelta(-1)">
<op-icon icon-classes="icon-zoom-in button--icon"></op-icon>
</button>
</li>
<li *ngIf="isActive()">
<button id="work-packages-timeline-zoom-out-button"
class="button timeline-toolbar--button toolbar-icon"
@ -31,6 +21,16 @@
</button>
</li>
<li *ngIf="isActive()">
<button id="work-packages-timeline-zoom-in-button"
class="button timeline-toolbar--button toolbar-icon"
[attr.title]="text.zoomIn"
[disabled]="currentZoom == minZoomLevel"
(click)="updateZoomWithDelta(-1)">
<op-icon icon-classes="icon-zoom-in button--icon"></op-icon>
</button>
</li>
<li>
<button class="button timeline-toolbar--button toolbar-icon"
[ngClass]="{ '-active': isActive() }"

@ -51,7 +51,7 @@ export abstract class WorkPackageRelationQueryBase {
* Request to refresh the results of the embedded table
*/
public refreshTable() {
this.embeddedTable.isInitialized && this.embeddedTable.refresh();
this.embeddedTable.isInitialized && this.embeddedTable.loadQuery(true, false);
}
/**

@ -43,7 +43,7 @@ export abstract class WorkPackageEmbeddedBaseComponent extends WorkPackagesViewB
ngAfterViewInit():void {
// Load initially
this.refresh(this.initialLoadingIndicator);
this.loadQuery(true, false);
}
ngOnDestroy():void {
@ -52,7 +52,7 @@ export abstract class WorkPackageEmbeddedBaseComponent extends WorkPackagesViewB
ngOnChanges(changes:SimpleChanges) {
if (this.initialized && (changes.queryId || changes.queryProps)) {
this.refresh(this.initialLoadingIndicator);
this.loadQuery(this.initialLoadingIndicator, false);
}
}
@ -80,7 +80,20 @@ export abstract class WorkPackageEmbeddedBaseComponent extends WorkPackagesViewB
}
public refresh(visible:boolean = true, firstPage:boolean = false):Promise<any> {
return this.loadQuery(visible, firstPage);
const query = this.querySpace.query.value!;
const pagination = this.wpTablePagination.paginationObject;
if (firstPage) {
pagination.offset = 1;
}
const promise = this.QueryDm.loadResults(query, pagination)
.then((results) => this.wpStatesInitialization.updateQuerySpace(query, results));
if (visible) {
this.loadingIndicator = promise;
}
return promise;
}
public get isInitialized() {
@ -95,7 +108,7 @@ export abstract class WorkPackageEmbeddedBaseComponent extends WorkPackagesViewB
}
}
protected abstract loadQuery(visible:boolean, firstPage:boolean):Promise<any>;
public abstract loadQuery(visible:boolean, firstPage:boolean):Promise<any>;
protected get queryProjectScope() {
if (!this.configuration.projectContext) {

@ -118,7 +118,7 @@ export class WorkPackageEmbeddedGraphComponent extends WorkPackageEmbeddedBaseCo
this.chartData = labelCountMaps;
}
protected loadQuery(visible:boolean = false) {
public loadQuery(visible:boolean = false) {
this.error = null;
let queries = this.datasets.map((dataset:any) => {

@ -23,8 +23,6 @@ export class WorkPackageEmbeddedTableComponent extends WorkPackageEmbeddedBaseCo
@Input() public tableActions:OpTableActionFactory[] = [];
@Input() public externalHeight:boolean = false;
@Output() public onFiltersChanged = new EventEmitter<QueryFilterInstanceResource[]>();
readonly QueryDm:QueryDmService = this.injector.get(QueryDmService);
readonly opModalService:OpModalService = this.injector.get(OpModalService);
readonly tableActionsService:OpTableActionsService = this.injector.get(OpTableActionsService);
@ -104,7 +102,10 @@ export class WorkPackageEmbeddedTableComponent extends WorkPackageEmbeddedBaseCo
.catch(() => this.formPromise = undefined);
}
protected loadQuery(visible:boolean = true, firstPage:boolean = false):Promise<QueryResource> {
public loadQuery(visible:boolean = true, firstPage:boolean = false):Promise<QueryResource> {
// Ensure we are loading the form.
this.formPromise = undefined;
if (this.loadedQuery) {
const query = this.loadedQuery;
this.loadedQuery = undefined;

@ -8,8 +8,7 @@
<!-- Filter container (if requested) -->
<filter-container *ngIf="configuration.withFilters"
[showFilterButton]="configuration.showFilterButton"
[filterButtonText]="configuration.filterButtonText"
(filtersChanged)="onFiltersChanged.emit($event)">
[filterButtonText]="configuration.filterButtonText">
</filter-container>

@ -178,7 +178,8 @@ export class WorkPackageTimelineHeaderController implements OnInit {
});
this.renderTimeSlices(vp, 'quarter', 15, vp.dateDisplayStart, vp.dateDisplayEnd, (start, cell) => {
cell.innerHTML = 'Q' + start.format('Q');
cell.innerHTML = this.I18n.t('js.timelines.quarter_label',
{ quarter_number: start.format('Q') });
cell.classList.add('-top-border');
cell.style.height = '30px';
});

@ -50,6 +50,8 @@ import {cloneHalResource} from "core-app/modules/hal/helpers/hal-resource-builde
import {IsolatedQuerySpace} from "core-app/modules/work_packages/query-space/isolated-query-space";
import {QueryFilterInstanceResource} from "core-app/modules/hal/resources/query-filter-instance-resource";
import {WorkPackageTableFiltersService} from "core-components/wp-fast-table/state/wp-table-filters.service";
import {debounceTime, distinctUntilChanged, skip} from "rxjs/operators";
import {combineLatest} from "rxjs";
export const globalSearchWorkPackagesSelector = 'global-search-work-packages';
@ -58,7 +60,6 @@ export const globalSearchWorkPackagesSelector = 'global-search-work-packages';
template: `
<wp-embedded-table *ngIf="!resultsHidden"
[queryProps]="queryProps"
(onFiltersChanged)="onFiltersChanged($event)"
[configuration]="tableConfiguration">
</wp-embedded-table>
`
@ -92,22 +93,19 @@ export class GlobalSearchWorkPackagesComponent implements OnInit, OnDestroy, Aft
}
ngAfterViewInit() {
this.globalSearchService
.searchTerm$
.pipe(
untilComponentDestroyed(this)
)
.subscribe(() => {
this.wpFilters.visible = false;
this.setQueryProps();
});
this.globalSearchService
.projectScope$
.pipe(
untilComponentDestroyed(this)
)
.subscribe((_projectScope) => this.setQueryProps());
combineLatest(
this.globalSearchService.searchTerm$,
this.globalSearchService.projectScope$
).pipe(
skip(1),
distinctUntilChanged(),
debounceTime(10),
untilComponentDestroyed(this)
)
.subscribe(([newSearchTerm, newProjectScope]) => {
this.wpFilters.visible = false;
this.setQueryProps();
});
this.globalSearchService
.resultsHidden$
@ -125,14 +123,6 @@ export class GlobalSearchWorkPackagesComponent implements OnInit, OnDestroy, Aft
// Nothing to do
}
public onFiltersChanged(filters:QueryFilterInstanceResource[]) {
if (this.wpTableFilters.isComplete(filters)) {
const query = cloneHalResource(this.querySpace.query.value!) as QueryResource;
query.filters = filters;
this.queryProps = this.UrlParamsHelper.buildV3GetQueryFromQueryResource(query);
}
}
private setQueryProps():void {
let filters:any[] = [];
let columns = ['id', 'project', 'subject', 'type', 'status', 'updatedAt'];

@ -55,6 +55,9 @@ import {WorkPackagesListChecksumService} from "core-components/wp-list/wp-list-c
import {WorkPackageQueryStateService} from "core-components/wp-fast-table/state/wp-table-base.service";
import {debugLog} from "core-app/helpers/debug_output";
import {WorkPackageFiltersService} from "core-components/filters/wp-filters/wp-filters.service";
import {QueryResource} from "core-app/modules/hal/resources/query-resource";
import {QueryDmService} from "core-app/modules/hal/dm-services/query-dm.service";
import {WorkPackageStatesInitializationService} from "core-components/wp-list/wp-states-initialization.service";
export abstract class WorkPackagesViewBase implements OnInit, OnDestroy {
@ -78,6 +81,8 @@ export abstract class WorkPackagesViewBase implements OnInit, OnDestroy {
readonly $transitions:TransitionService = this.injector.get(TransitionService);
readonly I18n:I18nService = this.injector.get(I18nService);
readonly wpStaticQueries:WorkPackageStaticQueriesService = this.injector.get(WorkPackageStaticQueriesService);
readonly QueryDm:QueryDmService = this.injector.get(QueryDmService);
readonly wpStatesInitialization:WorkPackageStatesInitializationService = this.injector.get(WorkPackageStatesInitializationService);
constructor(protected injector:Injector) {
}
@ -95,10 +100,18 @@ export abstract class WorkPackagesViewBase implements OnInit, OnDestroy {
}
private setupQueryObservers() {
this.querySpace.ready.fireOnStateChange(this.wpTablePagination.state,
'Query loaded').values$().pipe(
untilComponentDestroyed(this),
withLatestFrom(this.querySpace.query.values$())
this
.querySpace
.ready
.fireOnStateChange(
this.wpTablePagination.state,
'Query loaded'
)
.values$()
.pipe(
untilComponentDestroyed(this),
withLatestFrom(this.querySpace.query.values$()
)
).subscribe(([pagination, query]) => {
if (this.wpListChecksumService.isQueryOutdated(query, pagination)) {
this.wpListChecksumService.update(query, pagination);

@ -84,8 +84,11 @@ module API
end
def add_eager_loading(scope, current_user)
# The eager loading on status is required for the readonly? check in the
# work package schema
scope
.includes(WorkPackageRepresenter.to_eager_load)
.includes(:status)
.include_spent_hours(current_user)
.select('work_packages.*')
.distinct

@ -44,8 +44,6 @@ module OpenProject
'autologin_cookie_path' => '/',
'autologin_cookie_secure' => false,
'database_cipher_key' => nil,
'force_help_link' => nil,
'force_formatting_help_link' => nil,
'show_community_links' => true,
'log_level' => 'info',
'scm_git_command' => nil,
@ -70,6 +68,13 @@ module OpenProject
'rails_force_ssl' => false,
'rails_asset_host' => nil,
# Additional / overridden help links
'force_help_link' => nil,
'force_formatting_help_link' => nil,
# Impressum link to be set, nil by default (= hidden)
'impressum_link' => nil,
# user configuration
'default_comment_sort_order' => 'asc',
@ -124,6 +129,9 @@ module OpenProject
# Allow in-context translations to be loaded with CSP
'crowdin_in_context_translations' => true,
# Default gravatar image, set to something other than 404
# to ensure a default is returned
'gravatar_fallback_image' => '404',
'registration_footer' => {},

@ -128,12 +128,17 @@ module OpenProject
# Set the +raw+ argument to true to return the unmangled string
# from the database.
def self.version(raw = false)
case name
when :mysql
ActiveRecord::Base.connection.select_value('SELECT VERSION()')
when :postgresql
version = ActiveRecord::Base.connection.select_value('SELECT version()')
raw ? version : version.match(/\APostgreSQL (\S+)/i)[1]
@version ||= case name
when :mysql
ActiveRecord::Base.connection.select_value('SELECT VERSION()')
when :postgresql
ActiveRecord::Base.connection.select_value('SELECT version()')
end
if name == :postgresql
raw ? @version : @version.match(/\APostgreSQL (\S+)/i)[1]
else
@version
end
end

@ -37,7 +37,7 @@ module OpenProject
end
def help_link
OpenProject::Configuration.force_help_link.presence || links[:user_guides]
OpenProject::Configuration.force_help_link.presence || static_links[:user_guides]
end
def [](name)
@ -45,6 +45,34 @@ module OpenProject
end
def links
@links ||= static_links.merge(dynamic_links)
end
def has?(name)
@links.key? name
end
private
def dynamic_links
dynamic = {
help: {
href: help_link,
label: 'top_menu.help_and_support'
}
}
if impressum_link = OpenProject::Configuration.impressum_link
dynamic[:impressum] = {
href: impressum_link,
label: :label_impressum
}
end
dynamic
end
def static_links
{
upsale: {
href: 'https://www.openproject.org/enterprise-edition',
@ -90,6 +118,10 @@ module OpenProject
href: 'https://www.openproject.org/release-notes/',
label: :label_release_notes
},
data_privacy: {
href: 'https://www.openproject.org/data-privacy-and-security/',
label: :label_privacy_policy
},
report_bug: {
href: 'https://www.openproject.org/development/report-a-bug/',
label: :label_report_bug

@ -58,7 +58,7 @@ module OpenProject::TextFormatting
end
def determine_url_segments
if context[:request]
if request = context[:request]
return [request.protocol, request.host_with_port]
end

@ -32,6 +32,7 @@ require 'open_project/static/links'
module Redmine::MenuManager::TopMenu::HelpMenu
def render_help_top_menu_node(item = help_menu_item)
cache_key = OpenProject::Cache::CacheKey.key('help_top_menu_node',
OpenProject::Static::Links.links,
I18n.locale,
OpenProject::Static::Links.help_link)
Rails.cache.fetch(cache_key) do
@ -117,6 +118,12 @@ module Redmine::MenuManager::TopMenu::HelpMenu
class: 'drop-down--help-headline',
title: l('top_menu.additional_resources')
end
if OpenProject::Static::Links.has? :impressum
result << static_link_item(:impressum)
end
result << static_link_item(:data_privacy)
result << static_link_item(
:website,
href_suffix: "/?utm_source=unknown&utm_medium=op-instance&utm_campaign=website-help-menu"

@ -20,7 +20,13 @@ module OpenProject
# Automatically update the openproject user whenever their info change in the upstream identity provider
OpenProject::OmniAuth::Authorization.after_login do |user, auth_hash, context|
# see https://github.com/opf/openproject/blob/caa07c5dd470f82e1a76d2bd72d3d55b9d2b0b83/app/controllers/concerns/omniauth_login.rb#L148
user.update_attributes context.send(:omniauth_hash_to_user_attributes, auth_hash)
attributes = context.send(:omniauth_hash_to_user_attributes, auth_hash) || {}
attributes = attributes.with_indifferent_access
# Don't allow unsetting admin if user is already admin
attributes.delete(:admin) if user.admin?
user.update_attributes attributes
end
end

@ -119,7 +119,7 @@ AvatarHelper.class_eval do
def default_gravatar_options
options = { secure: Setting.protocol == 'https' }
options[:default] = '404'
options[:default] = OpenProject::Configuration.gravatar_fallback_image
options
end

@ -47,7 +47,7 @@ class CostlogController < ApplicationController
elsif @cost_entry.save
flash[:notice] = t(:notice_cost_logged_successfully)
redirect_back fallback_location: work_package_path(@cost_entry.work_package)
redirect_to work_package_path(@cost_entry.work_package)
else
render action: 'edit'

@ -172,7 +172,15 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
%>
<tr>
<td class="subject"><%= link_to_work_package work_package %></td>
<td><%= link_to pluralize(c.units, c.cost_type.unit, c.cost_type.unit_plural), {:controller => "/costlog", :action => "index", :cost_type_id => c.cost_type, :work_package_id => work_package} %></td>
<td>
<%= link_to pluralize(c.units, c.cost_type.unit, c.cost_type.unit_plural),
{
controller: "/cost_reports",
action: "index",
cost_type_id: c.cost_type,
project_id: work_package.project_id
} %>
</td>
<td><%= c.cost_type %></td>
<td class="currency"><%= c.costs_visible_by?(User.current) ? number_to_currency(c.real_costs) : "" %></td>
</tr>

@ -69,10 +69,9 @@ module OpenProject::Costs::Hooks
txt = pluralize(units, cost_type.unit, cost_type.unit_plural)
if create_link
# TODO why does this have project_id, work_package_id and cost_type_id params?
str_array << link_to(txt, { controller: '/costlog',
str_array << link_to(txt, { controller: '/cost_reports',
action: 'index',
project_id: work_package.project,
work_package_id: work_package,
project_id: work_package.project_id,
cost_type_id: cost_type },
title: cost_type.name)
else

@ -107,9 +107,7 @@ describe 'Assigned to me embedded query on my page', type: :feature, js: true do
hierarchies.enable_via_header
hierarchies.expect_mode_enabled
# Re-enabling resets collapsed state for now
hierarchies.expect_hierarchy_at assigned_work_package, collapsed: false
hierarchies.expect_leaf_at assigned_work_package_child
hierarchies.expect_hierarchy_at assigned_work_package, collapsed: true
end
end
end

@ -31,7 +31,7 @@ class Widget::Table::ReportTable < Widget::Table
def configure_walker
@walker ||= @subject.walker
@walker.for_final_row do |row, cells|
html = "<th scope='row' class='normal inner left'>#{show_row row}#{debug_fields(row)}</th>"
html = "<th scope='row' class='normal inner left -break-word'>#{show_row row}#{debug_fields(row)}</th>"
html << cells.join
html << "<td class='normal inner right'>#{show_result(row)}#{debug_fields(row)}</th>"
html.html_safe
@ -41,7 +41,7 @@ class Widget::Table::ReportTable < Widget::Table
subrows.flatten!
unless row.fields.empty?
subrows[0] = %{
<th class='top left' rowspan='#{subrows.size}'>#{show_row row}#{debug_fields(row)}</th>
<th class='top left -break-word' rowspan='#{subrows.size}'>#{show_row row}#{debug_fields(row)}</th>
#{subrows[0].gsub("class='normal", "class='top")}
<th class='top right' rowspan='#{subrows.size}'>#{show_result(row)}#{debug_fields(row)}</th>
}.html_safe

@ -142,9 +142,9 @@ describe SearchController, type: :controller do
it { expect(assigns(:results)).to_not include(work_package_4) }
end
describe '#results_by_type' do
it { expect(assigns(:results_by_type)).to be_a(Hash) }
it { expect(assigns(:results_by_type)['work_packages']).to eql(3) }
describe '#results_count' do
it { expect(assigns(:results_count)).to be_a(Hash) }
it { expect(assigns(:results_count)['work_packages']).to eql(3) }
end
describe '#view' do

@ -41,15 +41,6 @@ describe 'search/index', type: :helper do
assign(:project, project)
end
it 'renders correct result-by-type links' do
results_by_type = { 'work_packages' => 1, 'wiki_pages' => 1 }
response = helper.render_results_by_type(results_by_type)
expect(response).to have_selector('a', count: results_by_type.size)
expect(response).to include("/projects/#{project.identifier}/search")
expect(response).to include("scope=#{scope}")
end
describe '#highlight_tokens' do
let(:maximum_length) { 1300 }

@ -29,6 +29,14 @@
require 'spec_helper'
describe OpenProject::Database do
before do
described_class.instance_variable_set(:@version, nil)
end
after do
described_class.instance_variable_set(:@version, nil)
end
it 'should return the correct identifier' do
allow(OpenProject::Database).to receive(:adapter_name).and_return 'PostgresQL'

@ -77,6 +77,11 @@ describe MailHandler, type: :model do
let!(:mail_user) { FactoryBot.create :admin, mail: 'user@example.org' }
let!(:work_package) { FactoryBot.create :work_package, project: project }
before do
# Avoid trying to extract text
allow(OpenProject::Database).to receive(:allows_tsv?).and_return false
end
it 'should update a work package with attachment' do
expect(WorkPackage).to receive(:find_by).with(id: 123).and_return(work_package)
@ -93,6 +98,21 @@ describe MailHandler, type: :model do
expect(work_package.attachments.count).to eq(1)
expect(work_package.attachments.first.filename).to eq('Photo25.jpg')
end
context 'with existing attachment' do
let!(:attachment) { FactoryBot.create(:attachment, container: work_package) }
it 'does not replace it (Regression #29722)' do
work_package.reload
expect(WorkPackage).to receive(:find_by).with(id: 123).and_return(work_package)
# Mail with two attachemnts, one of which is skipped by signature.asc filename match
submit_email 'update_ticket_with_attachment_and_sig.eml', issue: { project: 'onlinestore' }
expect(work_package.attachments.length).to eq 2
end
end
end
describe '#category' do

@ -55,7 +55,7 @@ describe Queries::WorkPackages::Filter::AttachmentContentFilter, type: :model do
end
it 'finds WP through attachment content' do
expect(WorkPackage.joins(:attachments).where(instance.where))
expect(WorkPackage.joins(instance.joins).where(instance.where))
.to match_array [work_package]
end
end

@ -41,18 +41,20 @@ describe Queries::WorkPackages::Filter::SearchFilter, type: :model do
end
shared_examples "subject, description, and comment filter" do
subject { WorkPackage.joins(instance.joins).where(instance.where) }
context '' do
let!(:work_package) { FactoryBot.create(:work_package, subject: "A bogus subject", description: "And a short description") }
it 'finds in subject' do
instance.values = ['bogus subject']
expect(WorkPackage.eager_load(instance.includes).where(instance.where))
is_expected
.to match_array [work_package]
end
it 'finds in description' do
instance.values = ['short description']
expect(WorkPackage.eager_load(instance.includes).where(instance.where))
is_expected
.to match_array [work_package]
end
@ -61,7 +63,7 @@ describe Queries::WorkPackages::Filter::SearchFilter, type: :model do
journal.save
instance.values = [journal.notes]
expect(WorkPackage.eager_load(instance.includes).where(instance.where))
is_expected
.to match_array [work_package]
end
end
@ -91,13 +93,13 @@ describe Queries::WorkPackages::Filter::SearchFilter, type: :model do
it "finds in attachment content" do
instance.values = ['ipsum']
expect(WorkPackage.eager_load(instance.includes).where(instance.where))
expect(WorkPackage.joins(instance.joins).where(instance.where))
.to match_array [work_package]
end
it "finds in attachment file name" do
instance.values = [filename]
expect(WorkPackage.eager_load(instance.includes).where(instance.where))
expect(WorkPackage.joins(instance.joins).where(instance.where))
.to match_array [work_package]
end
end

@ -541,6 +541,15 @@ describe Query, type: :model do
end
end
context 'parent' do
let(:sort_by) { [['parent', 'asc'], ['start_date', 'asc']] }
it 'is valid' do
expect(query).to be_valid
expect(query.sort_criteria).to match_array [['id', 'asc'], ['start_date', 'asc']]
end
end
context 'partially invalid' do
let(:sort_by) { [['cf_0815', 'desc'], ['project', 'desc']] }

Loading…
Cancel
Save