Merge branch 'dev' into merge-release/16.2-20250718035236

This commit is contained in:
Alexander Brandon Coles
2025-07-18 08:59:00 +01:00
990 changed files with 38052 additions and 23841 deletions
+49
View File
@@ -0,0 +1,49 @@
name: downstream-ci
on:
push:
branches:
- dev
- release/*
paths-ignore:
- 'docs/**'
- 'help/**'
pull_request:
types: [opened, reopened, synchronize]
paths-ignore:
- 'docs/**'
- 'help/**'
- 'packaging/**'
- '.pkgr.yml'
permissions:
contents: read
jobs:
trigger_saas_tests:
permissions:
contents: none
if: github.repository == 'opf/openproject'
name: SaaS tests
runs-on: ubuntu-latest
steps:
- name: Trigger SaaS Tests
env:
TOKEN: ${{ secrets.OPENPROJECT_CI_TOKEN }}
REPOSITORY: opf/saas-openproject
WORKFLOW_ID: test-saas.yml
# ref:
# * on push this will be `dev` or a specific release branch (e.g. release/16.1) - there is always a matching branch for that downstream
# * on pull_request we use the PR branch's base (e.g. dev or a release branch) to match the above
# core_ref:
# * the name of the core branch that has to be checked out downstream to run the tests
# * that is either dev, release/16.1 (for instance), or the pull request's branch name
run: |
if [[ ! "${{ github.base_ref || github.ref_name }}" =~ dev|release/.+ ]]; then
echo "Base branch is not dev or release branch. Cannot check downstream tests."
exit 0
fi
curl -i --fail-with-body -H"authorization: Bearer $TOKEN" \
-XPOST -H"Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/$REPOSITORY/actions/workflows/$WORKFLOW_ID/dispatches \
-d '{"ref": "${{ github.base_ref || github.ref_name }}", "inputs": { "core_ref" : "${{ github.ref_name }}" }}'
+4 -5
View File
@@ -138,9 +138,6 @@ gem "rack-protection", "~> 3.2.0"
# https://github.com/kickstarter/rack-attack
gem "rack-attack", "~> 6.7.0"
# CSP headers
gem "secure_headers", "~> 7.1.0"
# Browser detection for incompatibility checks
gem "browser", "~> 6.2.0"
@@ -173,6 +170,9 @@ gem "paper_trail", "~> 16.0.0"
gem "op-clamav-client", "~> 3.4", require: "clamav"
# Global ID for polymorphic associations
gem "globalid", "~> 1.2"
# Recurring meeting events definition
gem "ice_cube", "~> 0.17.0"
@@ -214,7 +214,6 @@ gem "mini_magick", "~> 5.2.0", require: false
gem "validate_url"
# Storages support code
gem "dry-auto_inject"
gem "dry-container"
gem "dry-monads"
gem "dry-validation"
@@ -238,7 +237,7 @@ gem "lookbook", "~> 2.3.11"
# Require factory_bot for usage with openproject plugins testing
gem "factory_bot", "~> 6.5.0", require: false
# require factory_bot_rails for convenience in core development
gem "factory_bot_rails", "~> 6.4.4", require: false
gem "factory_bot_rails", "~> 6.5.0", require: false
gem "turbo_power", "~> 0.7.0"
gem "turbo-rails", "~> 2.0.0"
+44 -49
View File
@@ -107,7 +107,7 @@ PATH
remote: modules/calendar
specs:
openproject-calendar (1.0.0)
icalendar (~> 2.10.0)
icalendar (~> 2.11.2)
PATH
remote: modules/costs
@@ -161,7 +161,7 @@ PATH
remote: modules/meeting
specs:
openproject-meeting (1.0.0)
icalendar (~> 2.10.0)
icalendar (~> 2.11.2)
PATH
remote: modules/my_page
@@ -335,9 +335,9 @@ GEM
android_key_attestation (0.3.0)
anyway_config (2.7.2)
ruby-next-core (~> 1.0)
appsignal (4.2.0)
appsignal (4.5.17)
logger
rack
rack (>= 2.0.0)
ast (2.4.3)
attr_required (1.0.2)
auto_strip_attributes (2.6.0)
@@ -345,7 +345,7 @@ GEM
awesome_nested_set (3.8.0)
activerecord (>= 4.0.0, < 8.1)
aws-eventstream (1.4.0)
aws-partitions (1.1124.0)
aws-partitions (1.1125.0)
aws-sdk-core (3.226.2)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
@@ -353,10 +353,10 @@ GEM
base64
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.105.0)
aws-sdk-kms (1.106.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.191.0)
aws-sdk-s3 (1.192.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -481,9 +481,6 @@ GEM
dotenv (= 3.1.8)
railties (>= 6.1)
drb (2.2.3)
dry-auto_inject (1.1.0)
dry-core (~> 1.1)
zeitwerk (~> 2.6)
dry-configurable (1.3.0)
dry-core (~> 1.1)
zeitwerk (~> 2.6)
@@ -512,7 +509,7 @@ GEM
dry-logic (~> 1.5)
dry-types (~> 1.8)
zeitwerk (~> 2.6)
dry-types (1.8.2)
dry-types (1.8.3)
bigdecimal (~> 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
@@ -560,10 +557,10 @@ GEM
logger
factory_bot (6.5.4)
activesupport (>= 6.1.0)
factory_bot_rails (6.4.4)
factory_bot_rails (6.5.0)
factory_bot (~> 6.5)
railties (>= 5.0.0)
faraday (2.13.1)
railties (>= 6.1.0)
faraday (2.13.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -639,7 +636,7 @@ GEM
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-gmail_v1 (0.44.0)
google-apis-gmail_v1 (0.45.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-env (2.3.1)
base64 (~> 0.2)
@@ -697,8 +694,10 @@ GEM
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.8, >= 1.8.1)
terminal-table (>= 1.5.1)
icalendar (2.10.3)
icalendar (2.11.2)
base64
ice_cube (~> 0.16)
logger
ostruct
ice_cube (0.17.0)
ice_nine (0.11.2)
@@ -728,7 +727,7 @@ GEM
json_spec (1.1.5)
multi_json (~> 1.0)
rspec (>= 2.0, < 4.0)
jwt (2.10.1)
jwt (2.10.2)
base64
ladle (1.0.1)
open4 (~> 1.0)
@@ -737,7 +736,7 @@ GEM
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
lefthook (1.11.14)
lefthook (1.12.2)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
letter_opener_web (3.0.0)
@@ -780,7 +779,7 @@ GEM
net-pop
net-smtp
marcel (1.0.4)
markly (0.13.0)
markly (0.13.1)
matrix (0.4.3)
messagebird-rest (1.4.2)
meta-tags (2.22.1)
@@ -789,7 +788,7 @@ GEM
mime-types (3.7.0)
logger
mime-types-data (~> 3.2025, >= 3.2025.0507)
mime-types-data (3.2025.0603)
mime-types-data (3.2025.0701)
mini_magick (5.2.0)
benchmark
logger
@@ -805,7 +804,7 @@ GEM
mutex_m (0.3.0)
net-http (0.6.0)
uri
net-imap (0.5.8)
net-imap (0.5.9)
date
net-protocol
net-ldap (0.19.0)
@@ -873,7 +872,7 @@ GEM
openssl (> 2.0)
optimist (3.2.1)
os (1.1.4)
ostruct (0.6.1)
ostruct (0.6.2)
ox (2.14.23)
bigdecimal (>= 3.0)
paper_trail (16.0.0)
@@ -1049,14 +1048,14 @@ GEM
msgpack (>= 0.4.3)
optimist (>= 3.0.0)
rbtree3 (0.7.1)
rdoc (6.14.1)
rdoc (6.14.2)
erb
psych (>= 4.0.0)
recaptcha (5.19.0)
redcarpet (3.6.1)
redis (5.4.0)
redis-client (>= 0.22.0)
redis-client (0.24.0)
redis-client (0.25.0)
connection_pool
regexp_parser (2.10.0)
reline (0.6.1)
@@ -1145,7 +1144,7 @@ GEM
activesupport (>= 3.0.0)
i18n
iso8601
ruby-next-core (1.1.1)
ruby-next-core (1.1.2)
ruby-ole (1.2.13.1)
ruby-prof (1.7.2)
base64
@@ -1165,7 +1164,6 @@ GEM
nokogiri (>= 1.16.8)
scimitar (2.11.0)
rails (>= 7.0)
secure_headers (7.1.0)
securerandom (0.4.1)
selenium-devtools (0.138.0)
selenium-webdriver (~> 4.2)
@@ -1381,7 +1379,6 @@ DEPENDENCIES
disposable (~> 0.6.2)
doorkeeper (~> 5.8.0)
dotenv-rails
dry-auto_inject
dry-container
dry-monads
dry-validation
@@ -1391,12 +1388,13 @@ DEPENDENCIES
erblint-github
escape_utils (~> 1.3)
factory_bot (~> 6.5.0)
factory_bot_rails (~> 6.4.4)
factory_bot_rails (~> 6.5.0)
ffi (~> 1.15)
flamegraph
fog-aws
friendly_id (~> 5.5.0)
fuubar (~> 2.5.0)
globalid (~> 1.2)
gon (~> 6.4.0)
good_job (= 3.99.1)
google-apis-gmail_v1
@@ -1513,7 +1511,6 @@ DEPENDENCIES
rubytree (~> 2.1.0)
sanitize (~> 7.0.0)
scimitar (~> 2.11)
secure_headers (~> 7.1.0)
selenium-devtools
selenium-webdriver (~> 4.20)
semantic (~> 1.6.1)
@@ -1581,16 +1578,16 @@ CHECKSUMS
airbrake-ruby (6.2.2) sha256=293e34fb36e763e1b6d67ab584cce7c5b6fe9eea1a70c26d8c13c0f5d7de2fbc
android_key_attestation (0.3.0) sha256=467eb01a99d2bb48ef9cf24cc13712669d7056cba5a52d009554ff037560570b
anyway_config (2.7.2) sha256=30f6b087c0b41afdd43fe46c81d65a16f052a8489dab453abaeb4ea67aa74bad
appsignal (4.2.0) sha256=52c8b13cabe8991066b466e10b223a53168d837e7516775bbba18b4fbc758bc3
appsignal (4.5.17) sha256=1a1f8ed4e74ce8831365b5a6710b9c0313d249eb148b31325b291ccc9d6d9143
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
attr_required (1.0.2) sha256=f0ebfc56b35e874f4d0ae799066dbc1f81efefe2364ca3803dc9ea6a4de6cb99
auto_strip_attributes (2.6.0) sha256=a7e2e0cf744de2bcd947fd68014220702bcc88c81274c1cd9ce6f7316aae39b0
awesome_nested_set (3.8.0) sha256=469daff411d80291dbb80d1973133e498048a7afc2519c545f62d2cdebc60eda
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
aws-partitions (1.1124.0) sha256=6abc53e3c1300cde99738b0dd4dff9d0847797eaace9815f2784597418822bd5
aws-partitions (1.1125.0) sha256=f5b0085b37fc9da6746471f5ee3dde5a78d9d55270f5435daca9189f0f5ac273
aws-sdk-core (3.226.2) sha256=ef85b574ccfa6e8a3d59c1eafd2b7388752121d8e62eee4f777ca55df9c4c9c7
aws-sdk-kms (1.105.0) sha256=d7756c1c2e3a79afaceb7c590f3846d2814cf217e28971c243608c41d65bae4f
aws-sdk-s3 (1.191.0) sha256=62b541dbaee2925cbc9d21d2003cd4270f778ddbdb7a7dd58a5343514004dfd3
aws-sdk-kms (1.106.0) sha256=1737e3f154746dedcaf6aecf2e5490ab5e6447ac373bb5a57a19bb1184506007
aws-sdk-s3 (1.192.0) sha256=0cd451b4119c9228cc97f6fb9a1e99cf77b827f3a15cf3ada760be12b3a620d0
aws-sdk-sns (1.100.0) sha256=706bb13c5da789a5b9c6219b397980645536dd7f7301c09fbb8d3e8613f65a96
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
axe-core-api (4.10.3) sha256=6e10f3ed1c031804f16e8154d9d5dc658564d10850cee860e125fe665c3f0148
@@ -1655,7 +1652,6 @@ CHECKSUMS
dotenv (3.1.8) sha256=9e1176060ced581f8e6ce4384e91361817763a76e3c625c8bddc18b35bd392c3
dotenv-rails (3.1.8) sha256=46c9d1226a8b58a83b5f61325aa8cffd25cea1c0fafdfbbbee1e5dfea77980c4
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
dry-auto_inject (1.1.0) sha256=f9276cb5d15a3ef138e1f1149e289e287f636de57ef4a6decd233542eb708f78
dry-configurable (1.3.0) sha256=882d862858567fc1210d2549d4c090f34370fc1bb7c5c1933de3fe792e18afa8
dry-container (0.11.0) sha256=23be9381644d47343f3bf13b082b4255994ada0bfd88e0737eaaadc99d035229
dry-core (1.1.0) sha256=0903821a9707649a7da545a2cd88e20f3a663ab1c5288abd7f914fa7751ab195
@@ -1664,7 +1660,7 @@ CHECKSUMS
dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2
dry-monads (1.9.0) sha256=9348a67b5c862c7a876342dbd94737fdf3fb3c17978382cf6801a85b27215816
dry-schema (1.14.1) sha256=2fcd7539a7099cacae6a22f6a3a2c1846fe5afeb1c841cde432c89c6cb9b9ff1
dry-types (1.8.2) sha256=c84e9ada69419c727c3b12e191e0ed7d2c6d58d040d55e79ea16e0ebf8b3ec0f
dry-types (1.8.3) sha256=b5d97a45e0ed273131c0c3d5bc9f5633c2d1242e092ee47401ce7d5eab65c1bc
dry-validation (1.11.1) sha256=70900bb5a2d911c8aab566d3e360c6bff389b8bf92ea8e04885ce51c41ff8085
dumb_delegator (1.1.0) sha256=1ad255e5b095a2206a574c62b40c678f3d5c9151f1b3d0bae1b0463f7e40188e
em-http-request (1.1.7) sha256=16fbc72b2a6e20c804c564ac5d12e98668c6fcef8c3b1dd2387dff505f2efdab
@@ -1682,8 +1678,8 @@ CHECKSUMS
eventmachine_httpserver (0.2.1) sha256=5db5e8a23754204d43592e5fcc2160457c57c870babe6307c4e61fc95019b809
excon (1.2.7) sha256=3b3917dbdf0c65b8d872039fe2b37bf423da2f245ef05b0af07423027c4cfde5
factory_bot (6.5.4) sha256=4707fb7d80a7c14d71feb069460587bfc342e4ff1ef28097e0ad69d5ddfce613
factory_bot_rails (6.4.4) sha256=139e17caa2c50f098fddf5e5e1f29e8067352024e91ca1186d018b36589e5c88
faraday (2.13.1) sha256=cc531eb5467e7d74d4517630fa96f1a7003647cbf20a9a3e067d098941217b75
factory_bot_rails (6.5.0) sha256=4a7b61635424a57cc60412a18b72b9dcfb02fabfce2c930447a01dce8b37c0a2
faraday (2.13.2) sha256=5c19762e3bbe78e61d8007c5119f2968373c5296d6c6d6aa05b6f9cec34f2a1a
faraday-follow_redirects (0.3.0) sha256=d92d975635e2c7fe525dd494fcd4b9bb7f0a4a0ec0d5f4c15c729530fdb807f9
faraday-net_http (3.4.1) sha256=095757fae7872b94eac839c08a1a4b8d84fd91d6886cfbe75caa2143de64ab3b
fastimage (2.3.1) sha256=23c629f1f3e7d61bcfcc06c25b3d2418bc6bf41d2e615dbf5132c0e3b63ecce9
@@ -1714,7 +1710,7 @@ CHECKSUMS
gon (6.4.0) sha256=e3a618d659392890f1aa7db420f17c75fd7d35aeb5f8fe003697d02c4b88d2f0
good_job (3.99.1) sha256=7d3869d8a8ee8ef7048fee5d746f41c21987b7822c20038a2f773036bef0830a
google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee
google-apis-gmail_v1 (0.44.0) sha256=29df00caa538b8f32b6826f3ca2b659340e63baed021ac231800aeb3edda4a9e
google-apis-gmail_v1 (0.45.0) sha256=28b12356de6b78e136b5290976633978b23dc0013797b0d25f49bd36882cde78
google-cloud-env (2.3.1) sha256=0faac01eb27be78c2591d64433663b1a114f8f7af55a4f819755426cac9178e7
google-logging-utils (0.2.0) sha256=675462b4ea5affa825a3442694ca2d75d0069455a1d0956127207498fca3df7b
googleauth (1.14.0) sha256=62e7de11791890c3d3dc70582dfd9ab5516530e4e4f56d96451fd62c76475149
@@ -1738,7 +1734,7 @@ CHECKSUMS
i18n (1.14.7) sha256=ceba573f8138ff2c0915427f1fc5bdf4aa3ab8ae88c8ce255eb3ecf0a11a5d0f
i18n-js (4.2.3) sha256=ffb1a6d34afebd9ab2aff9bd86e8f2873191cbe8b9ac5c2c8efe4dfa1107ca3b
i18n-tasks (1.0.15) sha256=74f3e9e0e2db5d757c58e659f4269ee698c7a7532ed29066a2aaffc9efbdfb56
icalendar (2.10.3) sha256=0ebfc2672f9fa77b86b4d8c0e25e9b2319aad45a33319fed06d0be8ddd0cd485
icalendar (2.11.2) sha256=8260c5990d6fb96d7119854a4a24b97f444da6cdb77498039eb1a565bf4b9574
ice_cube (0.17.0) sha256=32deb45dda4b4acc53505c2f581f6d32b5afc04d29b9004769944a0df5a5fcbe
ice_nine (0.11.2) sha256=5d506a7d2723d5592dc121b9928e4931742730131f22a1a37649df1c1e2e63db
interception (0.5) sha256=a53818d636752a8df90d8c1bb2f7b6e13a7b828543cb02b50fbde98b849d7907
@@ -1751,11 +1747,11 @@ CHECKSUMS
json-schema (4.3.1) sha256=d5e68dc32b94408d0b06ad04f9382ccbb6fe5a44910e066f8547f56c471a7825
json_schemer (2.4.0) sha256=56cb6117bb5748d925b33ad3f415b513d41d25d0bbf57fe63c0a78ff05597c24
json_spec (1.1.5) sha256=7a77b97a92c787e2aa3fbc4a1239afc3342c781151dc98cfb81461b3b7cad10f
jwt (2.10.1) sha256=e6424ae1d813f63e761a04d6284e10e7ec531d6f701917fadcd0d9b2deaf1cc5
jwt (2.10.2) sha256=31e1ee46f7359883d5e622446969fe9c118c3da87a0b1dca765ce269c3a0c4f4
ladle (1.0.1) sha256=e8586964108c798d48bf57d2a65bd5602e8e5223a176b6602a0fb36c0bda90dc
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
launchy (3.1.1) sha256=72b847b5cc961589dde2c395af0108c86ff0119f42d4648d25b5440ebb10059e
lefthook (1.11.14) sha256=c11c55f5096f5d38068b66be8a33143899b7095f28a8145c9adf0b3eb611c098
lefthook (1.12.2) sha256=3383da9d80289eadf5140c2e0038b23f78d999060139b285db9e0aa01fff1e4b
letter_opener (1.10.0) sha256=2ff33f2e3b5c3c26d1959be54b395c086ca6d44826e8bf41a14ff96fdf1bdbb2
letter_opener_web (3.0.0) sha256=3f391efe0e8b9b24becfab5537dfb17a5cf5eb532038f947daab58cb4b749860
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
@@ -1767,14 +1763,14 @@ CHECKSUMS
lookbook (2.3.11) sha256=847b056cbebc5b004fd838b31ffe77364db2225f6a139f21e22b1952cdaf68e2
mail (2.8.1) sha256=ec3b9fadcf2b3755c78785cb17bc9a0ca9ee9857108a64b6f5cfc9c0b5bfc9ad
marcel (1.0.4) sha256=0d5649feb64b8f19f3d3468b96c680bae9746335d02194270287868a661516a4
markly (0.13.0) sha256=326a232c876cd95093d110b8d184be8effeadd776c3ffcefd59bdc8de7f59e0d
markly (0.13.1) sha256=01317a045fe89032732a520e71593deeb62187562ca82effa0ed3ea23b5fd46a
matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b
md_to_pdf (0.2.4)
messagebird-rest (1.4.2) sha256=1501e16ad76c87558f20f3b26cb39d05f587b349b44b8bf8056b040e9b495301
meta-tags (2.22.1) sha256=e5ae1febbd320d396c7226d7edb868e5d63466c14b9c8b06622a1a74e6dce354
method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5
mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56
mime-types-data (3.2025.0603) sha256=00a122cf046ef3867c428ed5e6d97e759027b0caa375da7fba33a9799c8a3037
mime-types-data (3.2025.0701) sha256=1c7df686911260bf9127c7263840b94fd73ed4193f62d625aefc217af101e161
mini_magick (5.2.0) sha256=2757ffbfdb1d38242d1da9ff1505360ab75d59dc02eb7ab79ff6d5acb1243f4a
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289
@@ -1786,7 +1782,7 @@ CHECKSUMS
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
my_page (1.0.0)
net-http (0.6.0) sha256=9621b20c137898af9d890556848c93603716cab516dc2c89b01a38b894e259fb
net-imap (0.5.8) sha256=52aa5fdfc1a8a3df1f793b20a327e95b5a9dfe1d733e1f0d53075d2dbcfcf593
net-imap (0.5.9) sha256=d95905321e1bd9f294ffc7ff8697be218eee1ec96c8504c0960964d0a0be33fc
net-ldap (0.19.0) sha256=be2a379ccbd28fc75fb70a94af74e3a9a6866b84574247fc243e0abdd2f82f3d
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
@@ -1840,7 +1836,7 @@ CHECKSUMS
openssl-signature_algorithm (1.3.0) sha256=a3b40b5e8276162d4a6e50c7c97cdaf1446f9b2c3946a6fa2c14628e0c957e80
optimist (3.2.1) sha256=8cf8a0fd69f3aa24ab48885d3a666717c27bc3d9edd6e976e18b9d771e72e34e
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
ostruct (0.6.1) sha256=09a3fb7ecc1fa4039f25418cc05ae9c82bd520472c5c6a6f515f03e4988cb817
ostruct (0.6.2) sha256=6d7302a299e400a2c248d6ce0dad18fc3a5714e8096facc25ffd0c54ee57cfc0
overviews (1.0.0)
ox (2.14.23) sha256=4a9aedb4d6c78c5ebac1d7287dc7cc6808e14a8831d7adb727438f6a1b461b66
paper_trail (16.0.0) sha256=e9b9f0fb1b8b590c8231cfa931b282ba92f90e066e393930a5e1c61ae4c5019d
@@ -1900,11 +1896,11 @@ CHECKSUMS
rb_sys (0.9.116) sha256=c879891018535d4362455197065ea580541b10ffdfa940bec512ec1dd9a7def4
rbtrace (0.5.2) sha256=a2d7d222ab81363aaa0e91337ddbf70df834885d401a80ea0339d86c71f31895
rbtree3 (0.7.1) sha256=ab60ead728a5491b70df4f4065e180b18dbab5319f817ce1dbf5dd906f26d8ba
rdoc (6.14.1) sha256=905efa796cd296ef252af4fb31fe41c073dee894de6aad715821f335c632516b
rdoc (6.14.2) sha256=9fdd44df130f856ae70cc9a264dfd659b9b40de369b16581f4ab746e42439226
recaptcha (5.19.0) sha256=b69c021a5b788da06901d2c95ab86f10a8634e6496343096cc5167f0318b2a39
redcarpet (3.6.1) sha256=d444910e6aa55480c6bcdc0cdb057626e8a32c054c29e793fa642ba2f155f445
redis (5.4.0) sha256=798900d869418a9fc3977f916578375b45c38247a556b61d58cba6bb02f7d06b
redis-client (0.24.0) sha256=ee65ee39cb2c38608b734566167fd912384f3c1241f59075e22858f23a085dbb
redis-client (0.25.0) sha256=927dfd07a37346fd9111d80cac3199d2a02b4c084c79260acf7a5057bd742910
regexp_parser (2.10.0) sha256=cb6f0ddde88772cd64bff1dbbf68df66d376043fe2e66a9ef77fcb1b0c548c61
reline (0.6.1) sha256=1afcc9d7cb1029cdbe780d72f2f09251ce46d3780050f3ec39c3ccc6b60675fb
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
@@ -1934,7 +1930,7 @@ CHECKSUMS
rubocop-rspec (3.6.0) sha256=c0e4205871776727e54dee9cc91af5fd74578001551ba40e1fe1a1ab4b404479
rubocop-rspec_rails (2.31.0) sha256=775375e18a26a1184a812ef3054b79d218e85601b9ae897f38f8be24dddf1f45
ruby-duration (3.2.3) sha256=eb3d13b1df85067a015a8fb2ed8f1eec842a3b721e47c9b6fd74d2f356069784
ruby-next-core (1.1.1) sha256=37886d41cf91072a0f14fa53b3e7e650adce1fe3b1227842bae32034dcce1d5f
ruby-next-core (1.1.2) sha256=1b095fe2e45929f2581b3ecdb8d92b601ce4d72c12a466057c10e23b6f0c4512
ruby-ole (1.2.13.1) sha256=578d10dd2a797a2b35a1286c6fb2c9525f67c24791346fc8015d39f0ffa3cb72
ruby-prof (1.7.2) sha256=270424fcac37e611f2d15a55226c4628e234f8434e1d7c25ca8a2155b9fc4340
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
@@ -1946,7 +1942,6 @@ CHECKSUMS
safety_net_attestation (0.4.0) sha256=96be2d74e7ed26453a51894913449bea0e072f44490021545ac2d1c38b0718ce
sanitize (7.0.0) sha256=269d1b9d7326e69307723af5643ec032ff86ad616e72a3b36d301ac75a273984
scimitar (2.11.0) sha256=77cf779a843be7d572046acdcf0a1829bd3b1c33db993fa83faf7f1863d8c625
secure_headers (7.1.0) sha256=6b1f9d5f9507af2948f4636452c41c09371927836396c2185438ffdf0a731124
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
selenium-devtools (0.138.0) sha256=596c08e114342dd89513bc3d18bbb2ae39e532864dbc25c09a48fb9922fc5b7b
selenium-webdriver (4.34.0) sha256=ec7bb718cbe66fe2b247d8ca5e6ba26caed0976d76579d7cb2fadd8dae8b271e
+34 -7
View File
@@ -28,7 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
class Activities::ItemComponent < ViewComponent::Base # rubocop:disable OpenProject/AddPreviewForViewComponent
class Activities::ItemComponent < ViewComponent::Base
with_collection_parameter :event
strip_trailing_whitespace
@@ -100,7 +100,7 @@ class Activities::ItemComponent < ViewComponent::Base # rubocop:disable OpenProj
end
def work_package?
data[:work_package]
data.key?(:work_package) || data[:entity_type] == "WorkPackage"
end
def data
@@ -125,18 +125,45 @@ class Activities::ItemComponent < ViewComponent::Base # rubocop:disable OpenProj
details = @event.journal.details
details.delete(:user_id) if details[:logged_by_id] == details[:user_id]
delete_detail(details, :work_package_id)
delete_detail(details, :comments)
delete_detail(details, :activity_id)
delete_detail(details, :spent_on)
remove_detail_when_changing_from_empty(details, "work_package_id")
remove_detail_when_changing_from_empty(details, "entity_type")
remove_detail_when_changing_from_empty(details, "entity_id")
remove_detail_when_changing_from_empty(details, "comments")
remove_detail_when_changing_from_empty(details, "activity_id")
remove_detail_when_changing_from_empty(details, "spent_on")
build_polymorphic_entity_gid_changeset(details)
details
end
def delete_detail(details, field)
def build_polymorphic_entity_gid_changeset(details)
return if !details.key?("entity_id") && !details.key?("entity_type")
details["entity_gid"] = [
build_gid(*type_and_id_for(details, "entity", index: 0)),
build_gid(*type_and_id_for(details, "entity", index: 1))
]
details.delete("entity_id")
details.delete("entity_type")
end
def type_and_id_for(details, field, index:)
[
details["#{field}_type"]&.at(index) || @event.journal.journable.public_send("#{field}_type"),
details["#{field}_id"]&.at(index) || @event.journal.journable.public_send("#{field}_id")
]
end
def remove_detail_when_changing_from_empty(details, field)
details.delete(field) if details[field] && details[field].first.nil?
end
def build_gid(entity_type, entity_id)
"gid://#{GlobalID.app}/#{entity_type}/#{entity_id}"
end
def render_event_details
data[:details].filter_map do |detail|
@event.journal.render_detail(detail, activity_page: @activity_page)
@@ -31,8 +31,6 @@
class AttributeHelpTexts::ShowDialogComponent < ApplicationComponent
include OpTurbo::Streamable
DIALOG_ID = "attribute-help-text-show-modal"
def initialize(attribute_help_text:, current_user: User.current)
super
@attribute_help_text = attribute_help_text
@@ -41,7 +39,7 @@ class AttributeHelpTexts::ShowDialogComponent < ApplicationComponent
private
def dialog_id = DIALOG_ID
def dialog_id = dom_id(@attribute_help_text, :dialog)
def title = @attribute_help_text.attribute_caption
@@ -67,8 +67,9 @@ See COPYRIGHT and LICENSE files for more details.
component.with_row(scheme: :default) { render_blank_slate }
else
rows.each do |row|
component.with_row(scheme: :default) do
render(row_class.new(row:, table: self))
row_instance = row_class.new(row: row, table: self)
component.with_row(scheme: row_instance.scheme) do
render(row_instance)
end
end
end
+4
View File
@@ -68,6 +68,10 @@ class RowComponent < ApplicationComponent
nil
end
def scheme
:default
end
def checkmark(condition)
if condition
helpers.op_icon "icon icon-checkmark"
@@ -31,7 +31,7 @@ See COPYRIGHT and LICENSE files for more details.
render(
FormControl.new(
input: @input,
classes: "pattern-input",
classes: "op-pattern-input",
"data-controller": "pattern-input",
"data-pattern-input-pattern-initial-value": @value,
"data-pattern-input-insert-as-text-template-value": I18n.t("types.edit.subject_configuration.pattern.insert_as_text"),
@@ -94,7 +94,7 @@ See COPYRIGHT and LICENSE files for more details.
Primer::BaseComponent.new(
tag: :div,
contenteditable: true,
border: true, border_radius: 2, p: 1, classes: :input,
border: true, border_radius: 2, p: 1, classes: "op-pattern-input--text-field",
"data-pattern-input-target": "content",
data: {
action: "keydown->pattern-input#input_keydown
@@ -106,7 +106,14 @@ See COPYRIGHT and LICENSE files for more details.
)
)
%>
<%= render(Primer::BaseComponent.new(tag: :div, box_shadow: :medium, border_radius: 2)) do %>
<%=
render Primer::BaseComponent.new(
tag: :div,
box_shadow: :medium,
classes: "op-pattern-input--suggestions-dropdown",
border_radius: 2
) do
%>
<focus-group direction="vertical">
<%= render(suggestions_list_component) %>
</focus-group>
@@ -50,7 +50,9 @@ module WorkPackageTypes
role: :list,
scheme: :inset,
ml: 0,
"data-pattern-input-target": "suggestions"
data: {
"pattern-input-target": "suggestions"
}
)
end
end
@@ -1,9 +1,20 @@
.pattern-input
.input
white-space: pre-wrap
.op-pattern-input
&--text-field
white-space: nowrap
overflow: hidden
height: var(--control-medium-size)
&:focus,
&:focus-visible
border-color: var(--focus-outlineColor) !important
box-shadow: inset 0 0 0 1px var(--focus-outlineColor)
outline: none
&--suggestions-dropdown
position: absolute
top: var(--control-medium-size)
background: var(--body-background)
z-index: 1
width: 100%
max-height: 300px
overflow: auto
@@ -0,0 +1,97 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 WorkPackageTypes
module Types
class RowComponent < ::RowComponent
include ApplicationHelper
include TypesHelper
def column_css_classes
super.merge(
name: "timelines-pet-name",
color: "timelines-pet-color",
default: "timelines-pet-is_default",
milestone: "timelines-pet-is_milestone",
sort: "timelines-pet-reorder"
)
end
def name
link_to model.name, edit_type_settings_path(type_id: model.id)
end
def workflow_warning
return unless model.workflows.empty?
safe_join([
op_icon("icon3 icon-warning"),
t(:text_type_no_workflow),
" (",
link_to(t(:button_edit), edit_workflows_path(type: model)),
")"
])
end
def color
icon_for_type model
end
def default
checked_image model.is_default
end
def milestone
checked_image model.is_milestone
end
def sort
helpers.reorder_links("type", { action: "move", id: model })
end
def button_links
[delete_link]
end
def delete_link
return if model.is_standard?
link_to(
"",
model,
method: :delete,
data: { confirm: t(:text_are_you_sure) },
class: "icon icon-delete",
title: t(:button_delete)
)
end
end
end
end
@@ -28,28 +28,33 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module Storages
module Peripherals
module StorageInteraction
module Inputs
class SetPermissionsContract < Dry::Validation::Contract
params do
required(:file_id).filled(:string)
required(:user_permissions).array(:hash) do
optional(:user_id).filled(:string)
optional(:group_id).filled(:string)
required(:permissions)
.array(:symbol, included_in?: OpenProject::Storages::Engine.external_file_permissions)
end
end
module WorkPackageTypes
module Types
class TableComponent < ::TableComponent
columns :name, :color, :workflow_warning, :default, :milestone, :sort
rule(:user_permissions).each do
both = value.key?(:user_id) && value.key?(:group_id)
none = !value.key?(:user_id) && !value.key?(:group_id)
def headers
[
[:name, { caption: Type.human_attribute_name(:name) }],
[:color, { caption: Type.human_attribute_name(:color) }],
[:workflow_warning, { caption: "Workflow" }],
[:default, { caption: I18n.t(:label_active_in_new_projects) }],
[:milestone, { caption: Type.human_attribute_name(:is_milestone) }],
[:sort, { caption: I18n.t(:button_sort) }]
]
end
key.failure("must have either user_id or group_id") if both || none
end
end
def header_options(name)
headers_hash = headers.to_h
headers_hash[name.to_sym] || { caption: name.to_s }
end
def mobile_title
I18n.t(:label_type_plural)
end
def sortable?
false
end
end
end
@@ -32,6 +32,9 @@ module WorkPackages
module DatePicker
class DateFormComponent < ApplicationComponent
include OpPrimer::ComponentHelpers
FOCUSED_CLASS = "op-datepicker-modal--date-field_current"
attr_reader :work_package
def initialize(work_package:,
@@ -78,7 +81,7 @@ module WorkPackages
disabled: disabled?(name),
label:,
show_clear_button: show_clear_button?(name),
classes: "op-datepicker-modal--date-field #{'op-datepicker-modal--date-field_current' if @focused_field == name}",
classes: "op-datepicker-modal--date-field #{FOCUSED_CLASS if focused?(name)}",
validation_message: validation_message(name),
type: field_type(name),
placeholder: placeholder(name)
@@ -165,6 +168,10 @@ module WorkPackages
@disabled
end
def focused?(name)
@focused_field == name && !disabled?(name)
end
def field_value(name)
errors = @work_package.errors.where(name)
if (user_value = errors.map { |error| error.options[:value] }.find { !it.nil? })
@@ -206,7 +213,7 @@ module WorkPackages
"focus->work-packages--date-picker--preview#onHighlightField",
test_selector: "op-datepicker-modal--#{name.to_s.dasherize}-field" }
if @focused_field == name && !disabled?(name)
if focused?(name)
data[:qa_highlighted] = "true"
data[:focus] = "true"
end
@@ -217,10 +224,10 @@ module WorkPackages
def single_date_field_button_link(focused_field)
permitted_params = params.merge(date_mode: "range", focused_field:).permit!
if params[:action] == "new"
new_date_picker_path(permitted_params)
if work_package.new_record?
preview_date_picker_path(permitted_params)
else
work_package_date_picker_path(permitted_params)
preview_work_package_date_picker_path(work_package, permitted_params)
end
end
@@ -1,108 +1,104 @@
<live-region></live-region>
<% if preview && live_region_message %>
<%= render OpTurbo::StreamComponent.new(action: :liveRegion, message: live_region_message, politeness: "polite", delay: "500", target: nil) %>
<% end %>
<%=
content_tag("turbo-frame", id: "wp-datepicker-dialog--content") do
%>
<live-region></live-region>
<% if @live_region_message %>
<%= render OpTurbo::StreamComponent.new(action: :liveRegion, message: @live_region_message, politeness: "polite", delay: "500", target: nil) %>
<% end %>
<%=
component_wrapper(
data: { controller: "work-packages--date-picker--preview",
"work-packages--date-picker--preview-date-mode-value": date_mode,
"work-packages--date-picker--preview-triggering-field-value": triggering_field,
"work-packages--date-picker--preview-schedule-manually-value": schedule_manually,
test_selector: "op-datepicker-modal" },
class: "wp-datepicker-dialog--content"
) do
component_collection do |collection|
if show_banner?
collection.with_component(WorkPackages::DatePicker::BannerComponent.new(work_package:, manually_scheduled: schedule_manually))
end
component_wrapper(
data: { controller: "work-packages--date-picker--preview",
"work-packages--date-picker--preview-date-mode-value": date_mode,
"work-packages--date-picker--preview-triggering-field-value": triggering_field,
"work-packages--date-picker--preview-schedule-manually-value": schedule_manually,
test_selector: "op-datepicker-modal" },
class: "wp-datepicker-dialog--content"
) do
component_collection do |collection|
if show_banner?
collection.with_component(WorkPackages::DatePicker::BannerComponent.new(work_package:, manually_scheduled: schedule_manually))
end
collection.with_component(Primer::Alpha::Dialog::Body.new(classes: "wp-datepicker-dialog--body", p: 0)) do
render(
Primer::Alpha::UnderlinePanels.new(
"aria-label": I18n.t("work_packages.datepicker_modal.tabs.aria_label"),
label: I18n.t("work_packages.datepicker_modal.tabs.aria_label"),
classes: "wp-datepicker-dialog--UnderlineNav",
px: 3
)
) do |component|
component.with_tab(selected: true, id: "wp-datepicker-dialog--content-tab--dates") do |tab|
tab.with_text { I18n.t("work_packages.datepicker_modal.tabs.dates") }
tab.with_panel(m: 3) do
render(
WorkPackages::DatePicker::FormComponent.new(
form_id: DIALOG_FORM_ID,
show_date_form: can_set_dates?,
work_package:,
schedule_manually:,
focused_field:,
triggering_field:,
touched_field_map:,
date_mode:
)
collection.with_component(Primer::Alpha::Dialog::Body.new(classes: "wp-datepicker-dialog--body", p: 0)) do
render(
Primer::Alpha::UnderlinePanels.new(
"aria-label": I18n.t("work_packages.datepicker_modal.tabs.aria_label"),
label: I18n.t("work_packages.datepicker_modal.tabs.aria_label"),
classes: "wp-datepicker-dialog--UnderlineNav",
px: 3
)
) do |component|
component.with_tab(selected: true, id: "wp-datepicker-dialog--content-tab--dates") do |tab|
tab.with_text { I18n.t("work_packages.datepicker_modal.tabs.dates") }
tab.with_panel(m: 3) do
render(
WorkPackages::DatePicker::FormComponent.new(
form_id: DIALOG_FORM_ID,
show_date_form: can_set_dates?,
work_package:,
schedule_manually:,
focused_field:,
triggering_field:,
touched_field_map:,
date_mode:
)
end
)
end
end
additional_tabs.each do |tab|
component.with_tab(id: "wp-datepicker-dialog--content-tab--#{tab.key}") do |tab_content|
relation_group = tab.relation_group
tab_content.with_text { I18n.t("work_packages.datepicker_modal.tabs.#{tab.key}") }
tab_content.with_counter(count: relation_group.count)
tab_content.with_panel(m: 3, classes: "wp-datepicker-dialog--content-tab--relation-tab") do
if relation_group.any?
render(border_box_container(padding: :condensed)) do |box|
relation_group.all_relation_items.each do |relation_item|
box.with_row(scheme: :default) do
render(
WorkPackageRelationsTab::RelationComponent.new(
relation_item:,
editable: false
)
additional_tabs.each do |tab|
component.with_tab(id: "wp-datepicker-dialog--content-tab--#{tab.key}") do |tab_content|
relation_group = tab.relation_group
tab_content.with_text { I18n.t("work_packages.datepicker_modal.tabs.#{tab.key}") }
tab_content.with_counter(count: relation_group.count)
tab_content.with_panel(m: 3, classes: "wp-datepicker-dialog--content-tab--relation-tab") do
if relation_group.any?
render(border_box_container(padding: :condensed)) do |box|
relation_group.all_relation_items.each do |relation_item|
box.with_row(scheme: :default) do
render(
WorkPackageRelationsTab::RelationComponent.new(
relation_item:,
editable: false
)
end
)
end
end
else
render(Primer::Beta::Blankslate.new(border: true)) do |blankslate|
blankslate.with_visual_icon(icon: :book, size: :medium)
blankslate.with_heading(tag: :h2) { I18n.t("work_packages.datepicker_modal.tabs.blankslate.#{tab.key}.title") }
blankslate.with_description { I18n.t("work_packages.datepicker_modal.tabs.blankslate.#{tab.key}.description") }
end
end
else
render(Primer::Beta::Blankslate.new(border: true)) do |blankslate|
blankslate.with_visual_icon(icon: :book, size: :medium)
blankslate.with_heading(tag: :h2) { I18n.t("work_packages.datepicker_modal.tabs.blankslate.#{tab.key}.title") }
blankslate.with_description { I18n.t("work_packages.datepicker_modal.tabs.blankslate.#{tab.key}.description") }
end
end
end
end
end
end
end
collection.with_component(Primer::Alpha::Dialog::Footer.new) do
component_collection do |footer|
footer.with_component(
Primer::Beta::Button.new(
data: { action: "work-packages--date-picker--preview#cancel" },
test_selector: "op-datepicker-modal--action"
)
) do
I18n.t("button_cancel")
end
collection.with_component(Primer::Alpha::Dialog::Footer.new) do
component_collection do |footer|
footer.with_component(
Primer::Beta::Button.new(
data: { action: "work-packages--date-picker--preview#cancel" },
test_selector: "op-datepicker-modal--action"
)
) do
I18n.t("button_cancel")
end
footer.with_component(
Primer::Beta::Button.new(
scheme: :primary,
type: :submit,
form: DIALOG_FORM_ID,
test_selector: "op-datepicker-modal--action",
disabled: !can_set_dates?
)
) do
I18n.t("button_save")
end
footer.with_component(
Primer::Beta::Button.new(
scheme: :primary,
type: :submit,
form: DIALOG_FORM_ID,
test_selector: "op-datepicker-modal--action",
disabled: !can_set_dates?
)
) do
I18n.t("button_save")
end
end
end
end
%>
<% end %>
end
%>
@@ -39,15 +39,15 @@ module WorkPackages
# Used for the three tabs for predecessors, successors and children in the date picker modal.
Tab = Data.define(:key, :relation_group)
attr_reader :work_package, :schedule_manually, :focused_field, :triggering_field, :touched_field_map, :date_mode
attr_reader :work_package, :schedule_manually, :focused_field, :triggering_field, :touched_field_map, :date_mode, :preview
def initialize(work_package:,
schedule_manually: true,
focused_field: :start_date,
triggering_field: nil,
touched_field_map: {},
live_region_message: "",
date_mode: nil)
date_mode: nil,
preview: false)
super
@work_package = work_package
@@ -56,11 +56,58 @@ module WorkPackages
@triggering_field = triggering_field
@touched_field_map = touched_field_map
@date_mode = date_mode
@live_region_message = live_region_message
@preview = preview
end
private
def live_region_message
message_parts = [
scheduling_mode_message,
working_days_message,
*date_message_parts
]
I18n.t(
"work_packages.datepicker_modal.update_inputs_aria_live_message",
message: message_parts.join(", ")
)
end
def scheduling_mode_message
mode_key = work_package.schedule_manually ? "manual" : "automatic"
mode = I18n.t("work_packages.datepicker_modal.mode.#{mode_key}")
"#{I18n.t('work_packages.datepicker_modal.mode.title')}: #{mode}"
end
def working_days_message
include_non_working = !!work_package.ignore_non_working_days
I18n.t("activerecord.attributes.work_package.include_non_working_days.#{include_non_working}")
end
def date_message_parts
[].tap do |parts|
parts << start_date_message if work_package.start_date.present?
parts << finish_date_message if work_package.due_date.present?
parts << duration_message if work_package.duration.present?
end
end
def start_date_message
"#{WorkPackage.human_attribute_name(:start_date)}: #{work_package.start_date}"
end
def finish_date_message
"#{WorkPackage.human_attribute_name(:due_date)}: #{work_package.due_date}"
end
def duration_message
[
WorkPackage.human_attribute_name(:duration),
I18n.t("datetime.distance_in_words.x_days", count: work_package.duration)
].join(": ")
end
def schedule_manually?
@schedule_manually
end
@@ -23,7 +23,6 @@
icon: :pin,
href: dialog_content_with(schedule_manually: true),
data: {
turbo_stream: true,
qa_selected: schedule_manually
},
test_selector: "op-datepicker-modal--scheduling_manual",
@@ -35,7 +34,6 @@
icon: "op-auto-date",
href: dialog_content_with(schedule_manually: false),
data: {
turbo_stream: true,
qa_selected: !schedule_manually
},
test_selector: "op-datepicker-modal--scheduling_automatic",
@@ -74,9 +74,9 @@ module WorkPackages
.merge(schedule_manually:)
.permit!
if work_package.new_record?
new_date_picker_path(dialog_params)
preview_date_picker_path(dialog_params)
else
work_package_date_picker_path(work_package, dialog_params)
preview_work_package_date_picker_path(work_package, dialog_params)
end
end
@@ -36,6 +36,8 @@ module WorkPackageTypes
attribute :is_in_roadmap
attribute :is_milestone
attribute :name
attribute :project_ids
attribute :attribute_groups
validates :name, presence: true, length: { maximum: 255 }
validates :is_default, :is_milestone, :is_in_roadmap, inclusion: { in: [true, false] }
@@ -56,6 +56,7 @@ class ApplicationController < ActionController::Base
include OpenProjectErrorHelper
include Security::DefaultUrlOptions
include OpModalFlashable
include DynamicContentSecurityPolicy
layout "base"
@@ -0,0 +1,50 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 DynamicContentSecurityPolicy
##
# Dynamically append sources to CSP directives
# This replaces the secure_headers named append functionality
def append_content_security_policy_directives(directives)
policy = current_content_security_policy
directives.each do |directive, source_values|
current_value = policy.send(directive) || policy.directives["default-src"]
new_values =
if current_value == %w('none') # rubocop:disable Lint/PercentStringArray
source_values.compact.uniq
else
(current_value + source_values).compact.uniq
end
policy.send(directive, *new_values)
request.content_security_policy = policy
end
end
end
@@ -120,6 +120,10 @@ module OpTurbo
.render_in(view_context)
end
def reload_page_via_turbo_stream
turbo_streams << OpTurbo::StreamComponent.new(action: :reloadPage, target: nil).render_in(view_context)
end
def turbo_streams
@turbo_streams ||= []
end
+2 -3
View File
@@ -82,8 +82,7 @@ class OAuthClientsController < ApplicationController
storage = oauth_client.integration
# check if the origin is the same
destination_url = destination_url(params.fetch(:destination_url, ""))
auth_state = ::Storages::Peripherals::StorageInteraction::Authentication
.authorization_state(storage:, user: User.current)
auth_state = ::Storages::Adapters::Authentication.authorization_state(storage:, user: User.current)
if auth_state == :connected
redirect_to(destination_url)
@@ -233,7 +232,7 @@ class OAuthClientsController < ApplicationController
end
def nextcloud?
@oauth_client&.integration&.provider_type == ::Storages::Storage::PROVIDER_TYPE_NEXTCLOUD
@oauth_client&.integration&.provider_type == ::Storages::NextcloudStorage.name
end
def one_drive?
@@ -30,6 +30,6 @@ class Projects::Settings::VersionsController < Projects::SettingsController
menu_item :settings_versions
def show
@versions = @project.shared_versions.order_by_semver_name
@versions = @project.shared_versions.order(:name)
end
end
+1 -2
View File
@@ -255,8 +255,7 @@ class RepositoriesController < ApplicationController
end
def stats
# allow object_src self to be able to load dynamic stats SVGs from ./graph
override_content_security_policy_directives object_src: %w('self')
append_content_security_policy_directives object_src: %w('self') # rubocop:disable Lint/PercentStringArray
@show_commits_per_author = current_user.allowed_in_project?(:view_commit_author_statistics, @project)
end
-170
View File
@@ -1,170 +0,0 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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.
#++
class TypesController < ApplicationController
include PaginationHelper
layout "admin"
before_action :require_admin
before_action :find_type, only: %i[update move destroy]
def index
@types = ::Type.page(page_param).per_page(per_page_param)
end
def type
@type
end
def new
@type = Type.new(params[:type])
load_projects_and_types
end
def edit
if params[:tab].blank?
redirect_to tab: :settings
else
type = ::Type.includes(:projects, :custom_fields).find(params[:id])
render_edit_tab(type)
end
end
def create
additional_params = {}
value = params.dig(:type, :copy_workflow_from)
additional_params[:copy_workflow_from] = value if value.present?
service_call = WorkPackageTypes::CreateService
.new(user: current_user)
.call(permitted_type_params.merge(additional_params))
@type = service_call.result
if service_call.success?
redirect_to edit_type_settings_path(@type), notice: t(:notice_successful_create), status: :see_other
else
render action: :new, status: :unprocessable_entity
end
end
def update
UpdateTypeService
.new(@type, current_user)
.call(permitted_type_params) do |call|
call.on_success { redirect_to_type_tab_path(@type, t(:notice_successful_update)) }
call.on_failure { render_edit_tab(@type, status: :unprocessable_entity) }
end
end
def move
if @type.update(permitted_params.type_move)
flash[:notice] = I18n.t(:notice_successful_update)
redirect_to types_path
else
flash.now[:error] = I18n.t(:error_type_could_not_be_saved)
render action: :edit, status: :unprocessable_entity
end
end
def destroy
# types cannot be deleted when they have work packages
# or they are standard types
# put that into the model and do a `if @type.destroy`
if @type.work_packages.empty? && !@type.is_standard?
@type.destroy
flash[:notice] = I18n.t(:notice_successful_delete)
else
flash[:error] = destroy_error_message
end
redirect_to action: "index"
end
protected
def find_type
@type = ::Type.find(params[:id])
end
def permitted_type_params
# having to call #to_unsafe_h as a query hash the attribute_groups
# parameters would otherwise still be an ActiveSupport::Parameter
permitted_params.type.to_unsafe_h
end
def load_projects_and_types
@types = ::Type.order(Arel.sql("position"))
@projects = Project.all
end
def redirect_to_type_tab_path(type, notice)
tab = params["tab"] || "settings"
redirect_to(edit_tab_type_path(type, tab:), notice:, status: :see_other)
end
def render_edit_tab(type, status: :ok)
@tab = params[:tab]
@projects = Project.all
@type = type
render action: :edit, status:
end
def destroy_error_message
if @type.is_standard?
t(:error_can_not_delete_standard_type)
else
error_message = [
ApplicationController.helpers.sanitize(
t(:"error_can_not_delete_type.explanation", url: belonging_wps_url(@type.id)),
attributes: %w(href target)
)
]
if archived_projects.any?
error_message << ApplicationController.helpers.sanitize(
t(:error_can_not_delete_in_use_archived_work_packages,
archived_projects_urls: helpers.archived_projects_urls_for(archived_projects)),
attributes: %w(href target)
)
end
error_message
end
end
def belonging_wps_url(type_id)
work_packages_path query_props: { f: [{ n: "type", o: "=", v: [type_id] }] }.to_json
end
def archived_projects
@archived_projects ||= @type.projects.archived
end
end
+1 -1
View File
@@ -168,7 +168,7 @@ class VersionsController < ApplicationController
versions = versions.or(@project.rolled_up_versions.includes(:custom_values))
end
versions = versions.visible.order_by_semver_name.except(:distinct).uniq
versions = versions.visible.order(:name).except(:distinct).uniq
versions.reject! { |version| version.closed? || version.completed? } unless completed
versions
end
@@ -31,10 +31,18 @@
module WorkPackageTypes
class PdfExportTemplateController < ApplicationController
include OpTurbo::ComponentStream
layout "admin"
before_action :require_admin
before_action :find_type, only: %i[toggle drop enable_all disable_all]
before_action :find_type, only: %i[edit toggle drop enable_all disable_all]
before_action :find_template, only: %i[toggle drop]
current_menu_item do
:types
end
def edit; end
def enable_all
return render_404_turbo_stream if @type.nil?
@@ -0,0 +1,147 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 WorkPackageTypes
class TypesController < ApplicationController
include PaginationHelper
layout "admin"
before_action :require_admin
before_action :find_type, only: %i[move destroy]
current_menu_item do
:types
end
def index
@types = ::Type
.includes(:workflows, :projects, :custom_fields, :color)
.page(page_param)
.per_page(per_page_param)
end
def type
@type
end
def new
@type = Type.new(params[:type])
load_projects_and_types
end
def create
additional_params = {}
value = params.dig(:type, :copy_workflow_from)
additional_params[:copy_workflow_from] = value if value.present?
service_call = WorkPackageTypes::CreateService
.new(user: current_user)
.call(permitted_type_params.merge(additional_params))
@type = service_call.result
if service_call.success?
redirect_to edit_type_settings_path(@type), notice: t(:notice_successful_create), status: :see_other
else
render action: :new, status: :unprocessable_entity
end
end
def move
if @type.update(permitted_params.type_move)
flash[:notice] = I18n.t(:notice_successful_update)
else
flash.now[:error] = I18n.t(:error_type_could_not_be_saved)
end
redirect_to types_path
end
def destroy
# types cannot be deleted when they have work packages
# or they are standard types
# put that into the model and do a `if @type.destroy`
if @type.work_packages.empty? && !@type.is_standard?
@type.destroy
flash[:notice] = I18n.t(:notice_successful_delete)
else
flash[:error] = destroy_error_message
end
redirect_to action: "index"
end
protected
def find_type
@type = ::Type.find(params[:id])
end
def permitted_type_params
# having to call #to_unsafe_h as a query hash the attribute_groups
# parameters would otherwise still be an ActiveSupport::Parameter
permitted_params.type.to_unsafe_h
end
def load_projects_and_types
@types = ::Type.order(Arel.sql("position"))
@projects = Project.all
end
def destroy_error_message
if @type.is_standard?
t(:error_can_not_delete_standard_type)
else
error_message = [
ApplicationController.helpers.sanitize(
t(:"error_can_not_delete_type.explanation", url: belonging_wps_url(@type.id)),
attributes: %w(href target)
)
]
if archived_projects.any?
error_message << ApplicationController.helpers.sanitize(
t(:error_can_not_delete_in_use_archived_work_packages,
archived_projects_urls: helpers.archived_projects_urls_for(archived_projects)),
attributes: %w(href target)
)
end
error_message
end
end
def belonging_wps_url(type_id)
work_packages_path query_props: { f: [{ n: "type", o: "=", v: [type_id] }] }.to_json
end
def archived_projects
@archived_projects ||= @type.projects.archived
end
end
end
@@ -29,62 +29,44 @@
# ++
class WorkPackages::DatePickerController < ApplicationController
include OpTurbo::ComponentStream
ERROR_PRONE_ATTRIBUTES = %i[start_date
due_date
duration].freeze
layout false
before_action :find_work_package, except: %i[new create]
authorization_checked! :show, :update, :edit, :new, :create
before_action :find_work_package, except: %i[new create preview]
authorization_checked! :show, :preview, :update, :edit, :new, :create
attr_accessor :work_package
def show
set_date_attributes_to_work_package
respond_to do |format|
format.html do
render :show,
locals: { work_package:, schedule_manually:, focused_field:, params: params.merge(date_mode: date_mode).permit! },
layout: false
end
format.turbo_stream do
replace_via_turbo_stream(
component: datepicker_modal_component
)
render turbo_stream: turbo_streams
end
end
render_form
end
def new
make_fake_initial_work_package
set_date_attributes_to_work_package
respond_to do |format|
format.turbo_stream do
replace_via_turbo_stream(
component: datepicker_modal_component
)
render turbo_stream: turbo_streams
end
format.html do
render datepicker_modal_component, status: :ok
end
end
render_form
end
def edit
set_date_attributes_to_work_package
render datepicker_modal_component
render_form
end
def preview
if params[:work_package_id]
find_work_package
else
make_fake_initial_work_package
end
set_date_attributes_to_work_package
render_form(preview: true)
end
# rubocop:disable Metrics/AbcSize
def create
make_fake_initial_work_package
service_call = set_date_attributes_to_work_package
@@ -92,17 +74,7 @@ class WorkPackages::DatePickerController < ApplicationController
if service_call.errors
.map(&:attribute)
.intersect?(ERROR_PRONE_ATTRIBUTES)
respond_to do |format|
format.turbo_stream do
# Bundle 422 status code into stream response so
# Angular has context as to the success or failure of
# the request in order to fetch the new set of Work Package
# attributes in the ancestry solely on success.
render turbo_stream: [
turbo_stream.morph("wp-datepicker-dialog--content", datepicker_modal_component)
], status: :unprocessable_entity
end
end
render_form(status: :unprocessable_entity)
else
render json: {
startDate: @work_package.start_date,
@@ -117,7 +89,6 @@ class WorkPackages::DatePickerController < ApplicationController
}
end
end
# rubocop:enable Metrics/AbcSize
def update
wp_params = work_package_datepicker_params
@@ -129,61 +100,25 @@ class WorkPackages::DatePickerController < ApplicationController
.call(wp_params)
if service_call.success?
respond_to do |format|
format.turbo_stream do
render turbo_stream: []
end
end
head :ok
else
respond_to do |format|
format.turbo_stream do
# Bundle 422 status code into stream response so
# Angular has context as to the success or failure of
# the request in order to fetch the new set of Work Package
# attributes in the ancestry solely on success.
render turbo_stream: [
turbo_stream.morph("wp-datepicker-dialog--content", datepicker_modal_component)
], status: :unprocessable_entity
end
end
render_form(status: :unprocessable_entity)
end
end
private
def datepicker_modal_component
WorkPackages::DatePicker::DialogContentComponent.new(work_package: @work_package,
schedule_manually:,
focused_field:,
triggering_field: params[:triggering_field],
touched_field_map:,
date_mode:,
live_region_message:)
end
def live_region_message
message_parts = [
"Scheduling mode: #{scheduling_label}",
working_days_label
]
message_parts << "Start date: #{@work_package.start_date}" if @work_package.start_date.present?
message_parts << "Finish date: #{@work_package.due_date}" if @work_package.due_date.present?
message_parts << "Duration: #{@work_package.duration} days" if @work_package.duration.present?
I18n.t(
"work_packages.datepicker_modal.update_inputs_aria_live_message",
message: message_parts.join(", ")
)
end
def working_days_label
I18n.t("activerecord.attributes.work_package.include_non_working_days.#{@work_package.ignore_non_working_days || false}")
end
def scheduling_label
mode = @work_package.schedule_manually ? "manual" : "automatic"
I18n.t("work_packages.datepicker_modal.mode.#{mode}")
def render_form(status: :ok, preview: false)
render :show,
locals: {
work_package:,
schedule_manually:,
focused_field:,
touched_field_map:,
date_mode:,
preview:
},
status:
end
def focused_field
@@ -38,7 +38,7 @@ class WorkPackages::ProgressController < ApplicationController
done_ratio].freeze
layout false
authorization_checked! :new, :edit, :create, :update
authorization_checked! :new, :edit, :preview, :create, :update
def new
make_fake_initial_work_package
+6
View File
@@ -219,6 +219,12 @@ module ApplicationHelper
end
end
# Backward compatibility helper for secure_headers gem migration
# Rails built-in CSP equivalent of nonced_javascript_tag
def nonced_javascript_tag(**, &)
javascript_tag(nonce: true, **, &)
end
def to_path_param(path)
path.to_s
end
+1 -1
View File
@@ -66,7 +66,7 @@ module FrontendAssetHelper
end
def nonced_javascript_include_tag(path, **)
javascript_include_tag(path, nonce: content_security_policy_script_nonce, **)
javascript_include_tag(path, nonce: content_security_policy_nonce, **)
end
private
+2 -4
View File
@@ -29,10 +29,8 @@
module SecureHeadersHelper
##
# Output a rails +csp_meta_tag+ compatible tag
# while we're still using the +secure_headers+ gem.
# using Rails built-in CSP functionality.
def secure_header_csp_meta_tag
tag :meta,
name: "csp-nonce",
content: content_security_policy_script_nonce
csp_meta_tag
end
end
+1 -1
View File
@@ -55,7 +55,7 @@ module ::TypesHelper
},
{
name: "export_configuration",
path: edit_tab_type_path(id: @type.id, tab: :export_configuration),
path: edit_type_pdf_export_template_index_path(type_id: @type.id),
label: I18n.t("types.edit.export_configuration.tab"),
view_component: WorkPackageTypes::ExportConfigurationComponent
}
+1 -5
View File
@@ -78,11 +78,7 @@ module Activities
when :default
OpenProject::Activity.default_event_types.to_a
else
scope = Array(scope)
scope << "project_details" if scope.delete("project_attributes")
scope & event_types
Array(scope) & event_types
end
end
+7 -1
View File
@@ -1,3 +1,5 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -32,7 +34,7 @@ class Comment < ApplicationRecord
validates :commented, :author, :comments, presence: true
after_create :send_news_comment_added_mail
after_create :send_comment_added_event
def text
comments
@@ -44,6 +46,10 @@ class Comment < ApplicationRecord
private
def send_comment_added_event
send_news_comment_added_mail if commented_type == News.name
end
def send_news_comment_added_mail
OpenProject::Notifications.send(OpenProject::Events::NEWS_COMMENT_CREATED,
comment: self,
@@ -352,12 +352,7 @@ class Journable::HistoricActiveRecordRelation < ActiveRecord::Relation
gsub_table_names_in_sql_string!(node.left)
elsif node.kind_of?(Arel::Nodes::Join) and node.right.kind_of?(Arel::Nodes::On)
[node.right.expr.left, node.right.expr.right].each do |attribute|
if attribute.respond_to? :relation and
(attribute.relation == journal_class.arel_table) and
(attribute.name == "id")
attribute.relation = Journal.arel_table
attribute.name = "journable_id"
end
modify_conditions(attribute)
end
end
end
@@ -439,6 +434,13 @@ class Journable::HistoricActiveRecordRelation < ActiveRecord::Relation
# expr, which is a NodeExpression itself
[node.expr.left, node.expr.right].each { |child| modify_conditions(child) }
elsif node.kind_of?(Arel::Attributes::Attribute)
# With the replacement of the table ivar we might already have switched the base relation ... so if the ID is used
# in a join, we should also replace it with the journable
if node.relation == model.journal_class.arel_table && node.name == "id"
node.relation = Journal.arel_table
node.name = "journable_id"
end
# We find an attribute, figure out if it is the model's table
if node.relation == model.arel_table
if node.name == "id"
-641
View File
@@ -1,641 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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.
#++
class MailHandler < ActionMailer::Base
include ActionView::Helpers::SanitizeHelper
include Redmine::I18n
class UnauthorizedAction < StandardError; end
class MissingInformation < StandardError; end
attr_reader :email, :sender_email, :user, :options, :logs
def initialize
super
@result = false
@logs = []
end
##
# Code copied from base class and extended with optional options parameter
# as well as force_encoding support.
def self.receive(raw_mail, options = {})
raw_mail.force_encoding("ASCII-8BIT") if raw_mail.respond_to?(:force_encoding)
ActiveSupport::Notifications.instrument("receive.action_mailer") do |payload|
mail = Mail.new(raw_mail)
set_payload_for_mail(payload, mail)
with_options(options).receive(mail)
end
end
def self.with_options(options)
handler = new
handler.options = options
handler
end
cattr_accessor :ignored_emails_headers
@@ignored_emails_headers = {
"X-Auto-Response-Suppress" => "oof",
"Auto-Submitted" => /\Aauto-/
}
# Processes incoming emails
# Returns the created object (eg. an issue, a message) or false
def receive(email)
@email = email
@sender_email = email.from.to_a.first.to_s.strip
# Ignore emails received from the application emission address to avoid hell cycles
if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
log "ignoring email from emission address [#{sender_email}]", report: false
# don't report back errors to ourselves
return false
end
# Ignore auto generated emails
self.class.ignored_emails_headers.each do |key, ignored_value|
value = email.header[key]
if value
value = value.to_s.downcase
if (ignored_value.is_a?(Regexp) && value.match(ignored_value)) || value == ignored_value
log "ignoring email with #{key}:#{value} header", report: false
# no point reporting back in case of auto-generated emails
return false
end
end
end
@user = User.find_by_mail(sender_email) if sender_email.present?
if @user && !@user.active?
log "ignoring email from non-active user [#{@user.login}]"
return false
end
if @user.nil?
# Email was submitted by an unknown user
case options[:unknown_user]
when "accept"
@user = User.anonymous
when "create"
@user, password = MailHandler::UserCreator.create_user_from_email(email)
if @user
log "[#{@user.login}] account created"
UserMailer.account_information(@user, password).deliver_later
else
log "could not create account for [#{sender_email}]", :error
return false
end
else
# Default behaviour, emails from unknown users are ignored
log "ignoring email from unknown user [#{sender_email}]", report: false
return false
end
end
User.current = @user
dispatch
ensure
report_errors if !@result && Setting.report_incoming_email_errors?
end
def options=(value)
@options = value.dup
options[:issue] ||= {}
options[:allow_override] = allow_override_option(options).map(&:to_sym).to_set
# Project needs to be overridable if not specified
options[:allow_override] << :project unless options[:issue].has_key?(:project)
# Status overridable by default
options[:allow_override] << :status unless options[:issue].has_key?(:status)
# Version overridable by default
options[:allow_override] << :version unless options[:issue].has_key?(:version)
# Type overridable by default
options[:allow_override] << :type unless options[:issue].has_key?(:type)
# Priority overridable by default
options[:allow_override] << :priority unless options[:issue].has_key?(:priority)
options[:no_permission_check] = options[:no_permission_check].to_s == "1"
end
private
# Dispatches the mail to the most appropriate method:
# * If there is no References header the email is interpreted as a new work package
# * If there is a References header the email is interpreted to update an existing entity (e.g. a work package
# or a message)
#
# OpenProject includes the necessary references in the References header of outgoing mail (see ApplicationMailer).
# This stretches the standard in that the values do not reference existing mails but it has the advantage of being able
# identify the object the response is destined for without human interference. Email clients will not remove
# entries from the References header but only add to it.
#
# OpenProject also sets the Message-ID header but gateways such as postmark, unless explicitly instructed otherwise,
# will use their own Message-ID and overwrite the provided one. As an email client includes the value thereof
# in the In-Reply-To and in the References header the Message-ID could also have been used.
#
# Relying on the subject of the mail, which had been implemented before, is brittle as it relies on the user not altering
# the subject. Additionally, the subject structure might change, e.g. via localization changes.
def dispatch
m, object_id = dispatch_target_from_header
@result = m ? m.call(object_id) : dispatch_to_default
rescue ActiveRecord::RecordInvalid => e
log "could not save record: #{e.message}", :error
false
rescue MissingInformation => e
log "missing information from #{user}: #{e.message}", :error
false
rescue UnauthorizedAction
log "unauthorized attempt from #{user}", :error
false
end
# Dispatch the mail to the default method handler, receive_work_package
#
# This can be overridden or patched to support handling other incoming
# email types
def dispatch_to_default
receive_work_package
end
REFERENCES_RE = %r{^<?op\.([a-z_]+)-(\d+)@}
##
# Find a matching method to dispatch to given the mail's references header.
# We set this header in outgoing emails to include an encoded reference to the object
def dispatch_target_from_header
headers = [email.references].flatten.compact
if headers.reverse.detect { |h| h.to_s =~ REFERENCES_RE }
klass = $1
object_id = $2.to_i
method_name = :"receive_#{klass}_reply"
if self.class.private_instance_methods.include?(method_name)
[method(method_name), object_id]
end
end
end
# Creates a new work package
def receive_work_package
project = target_project
result = create_work_package(project)
if result.is_a?(WorkPackage)
log "work_package ##{result.id} created by #{user}"
result
else
log "work_package could not be created by #{user} due to ##{result.full_messages}", :error
false
end
end
def receive_journal_reply(journal_id)
journal = Journal.find_by(id: journal_id)
return unless journal
send(:"receive_#{journal.journable_type.underscore}_reply", journal.journable_id)
end
# Adds a note to an existing work package
def receive_work_package_reply(work_package_id)
work_package = WorkPackage.find_by(id: work_package_id)
return unless work_package
# ignore CLI-supplied defaults for new work_packages
options[:issue].clear
result = update_work_package(work_package)
if result.is_a?(WorkPackage)
log "work_package ##{result.id} updated by #{user}"
result.last_journal
else
log "work_package could not be updated by #{user} due to ##{result.full_messages}", :error
false
end
end
# Receives a reply to a forum message
def receive_message_reply(message_id)
message = Message.find_by(id: message_id)
if message
message = message.root
if !options[:no_permission_check] && !user.allowed_in_project?(:add_messages, message.project)
raise UnauthorizedAction
end
if message.locked?
log "ignoring reply from [#{sender_email}] to a locked topic"
else
reply = Message.new(subject: email.subject.gsub(%r{^.*msg\d+\]}, "").strip,
content: cleaned_up_text_body)
reply.author = user
reply.forum = message.forum
message.children << reply
add_attachments(reply)
reply
end
end
end
def add_attachments(container)
return [] unless email.attachments&.present?
email
.attachments
.reject { |attachment| ignored_filename?(attachment.filename) }
.filter_map { |attachment| create_attachment(attachment, container) }
end
def create_attachment(attachment, container)
file = OpenProject::Files.create_uploaded_file(
name: attachment.filename,
content_type: attachment.mime_type,
content: attachment.decoded,
binary: true
)
call = ::Attachments::CreateService
.new(user:)
.call(container:, filename: attachment.filename, file:)
call.on_failure do
log "Failed to add attachment #{attachment.filename} for [#{sender_email}]: #{call.message}"
end
call.result
end
# Adds To and Cc as watchers of the given object if the sender has the
# appropriate permission
def add_watchers(obj)
if user.allowed_in_project?(:"add_#{obj.class.name.underscore}_watchers", obj.project) ||
user.allowed_in_project?(:"add_#{obj.class.lookup_ancestors.last.name.underscore}_watchers", obj.project)
addresses = [email.to, email.cc].flatten.compact.uniq.map { |a| a.strip.downcase }
unless addresses.empty?
User
.active
.where(["LOWER(mail) IN (?)", addresses])
.find_each do |user|
Services::CreateWatcher
.new(obj, user)
.run
end
# FIXME: somehow the watchable attribute of the new watcher is not set, when the issue is not safed.
# So we fix that here manually
obj.watchers.each do |w|
w.watchable = obj
end
end
end
end
def get_keyword(attr, options = {})
@keywords ||= {}
if @keywords.has_key?(attr)
@keywords[attr]
else
@keywords[attr] = begin
if (options[:override] || self.options[:allow_override].include?(attr)) &&
(v = extract_keyword!(plain_text_body, attr, options[:format]))
v
else
# Return either default or nil
self.options[:issue][attr]
end
end
end
end
# Destructively extracts the value for +attr+ in +text+
# Returns nil if no matching keyword found
def extract_keyword!(text, attr, format)
keys = human_attr_translations(attr)
.compact_blank
.uniq
.map { |k| Regexp.escape(k) }
value = nil
text.gsub!(/^(#{keys.join('|')})[ \t]*:[ \t]*(?<value>#{format || '.+'})\s*$/i) do |_|
value = Regexp.last_match[:value]&.strip
""
end
value
end
def human_attr_translations(attr)
keys = [
attr.to_s,
attr.to_s.humanize
]
[user&.language, Setting.default_language].compact_blank.each do |lang|
keys << all_attribute_translations(lang)[attr]
end
keys
end
def target_project
# TODO: other ways to specify project:
# * parse the email To field
# * specific project (eg. Setting.mail_handler_target_project)
target = Project.find_by(identifier: get_keyword(:project))
raise MissingInformation.new("Unable to determine target project") if target.nil?
target
end
# Returns a Hash of issue attributes extracted from keywords in the email body
def wp_attributes_from_keywords(work_package)
{
"assigned_to_id" => wp_assignee_from_keywords(work_package),
"category_id" => wp_category_from_keywords(work_package),
"due_date" => wp_due_date_from_keywords,
"estimated_hours" => wp_estimated_hours_from_keywords,
"parent_id" => wp_parent_from_keywords,
"priority_id" => wp_priority_from_keywords,
"remaining_hours" => wp_remaining_hours_from_keywords,
"responsible_id" => wp_accountable_from_keywords(work_package),
"start_date" => wp_start_date_from_keywords,
"status_id" => wp_status_from_keywords,
"type_id" => wp_type_from_keywords(work_package),
"version_id" => wp_version_from_keywords(work_package)
}.compact_blank!
end
# Returns a Hash of issue custom field values extracted from keywords in the email body
def custom_field_values_from_keywords(customized)
"#{customized.class.name}CustomField".constantize.all.inject({}) do |h, v|
if value = get_keyword(v.name, override: true)
h[v.id.to_s] = v.value_of value
end
h
end
end
def lookup_case_insensitive_key(scope, attribute, column_name = Arel.sql("name"))
if k = get_keyword(attribute)
scope.find_by("lower(#{column_name}) = ?", k.downcase).try(:id)
end
end
# Returns the text/plain part of the email
# If not found (eg. HTML-only email), returns the body with tags removed
def plain_text_body
return @plain_text_body unless @plain_text_body.nil?
part = email.text_part || email.html_part || email
@plain_text_body = Redmine::CodesetUtil.to_utf8(part.body.decoded, part.charset)
# strip html tags and remove doctype directive
# Note: In Rails 5, `strip_tags` also encodes HTML entities
@plain_text_body = strip_tags(@plain_text_body.strip)
@plain_text_body = CGI.unescapeHTML(@plain_text_body)
@plain_text_body.sub! %r{^<!DOCTYPE .*$}, ""
@plain_text_body
end
def cleaned_up_text_body
cleanup_body(plain_text_body)
end
def self.full_sanitizer
@full_sanitizer ||= Rails::Html::FullSanitizer.new
end
def allow_override_option(options)
if options[:allow_override].is_a?(String)
options[:allow_override].split(",").map(&:strip)
else
options[:allow_override] || []
end
end
# Removes the email body of text after the truncation configurations.
def cleanup_body(body)
delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).compact_blank.map { |s| Regexp.escape(s) }
unless delimiters.empty?
regex = Regexp.new("^[> ]*(#{delimiters.join('|')})\s*[\r\n].*", Regexp::MULTILINE)
body = body.gsub(regex, "")
end
regex_delimiter = Setting.mail_handler_body_delimiter_regex
if regex_delimiter.present?
regex = Regexp.new(regex_delimiter, Regexp::MULTILINE)
body = body.gsub(regex, "")
end
body.strip
end
def ignored_filenames
@ignored_filenames ||= Setting.mail_handler_ignore_filenames.to_s.split(/[\r\n]+/).compact_blank
end
def ignored_filename?(filename)
ignored_filenames.any? do |line|
filename.match? Regexp.escape(line)
end
end
def create_work_package(project)
work_package = WorkPackage.new(project:)
attributes = collect_wp_attributes_from_email_on_create(work_package)
service_call = WorkPackages::CreateService
.new(user:,
contract_class: work_package_create_contract_class)
.call(**attributes.merge(work_package:).symbolize_keys)
if service_call.success?
work_package = service_call.result
add_watchers(work_package)
add_attachments(work_package)
work_package
else
service_call.errors
end
end
def collect_wp_attributes_from_email_on_create(work_package)
attributes = wp_attributes_from_keywords(work_package)
attributes
.merge("custom_field_values" => custom_field_values_from_keywords(work_package),
"subject" => email.subject.to_s.chomp[0, 255] || "(no subject)",
"description" => cleaned_up_text_body)
end
def update_work_package(work_package)
attributes = collect_wp_attributes_from_email_on_update(work_package)
attributes[:attachment_ids] = work_package.attachment_ids + add_attachments(work_package).map(&:id)
service_call = WorkPackages::UpdateService
.new(user:,
model: work_package,
contract_class: work_package_update_contract_class)
.call(**attributes.symbolize_keys)
if service_call.success?
service_call.result
else
service_call.errors
end
end
def collect_wp_attributes_from_email_on_update(work_package)
attributes = wp_attributes_from_keywords(work_package)
attributes
.merge("custom_field_values" => custom_field_values_from_keywords(work_package),
"journal_notes" => cleaned_up_text_body)
end
def wp_type_from_keywords(work_package)
lookup_case_insensitive_key(work_package.project.types, :type) ||
(work_package.new_record? && work_package.project.types.first.try(:id))
end
def wp_status_from_keywords
lookup_case_insensitive_key(Status, :status)
end
def wp_parent_from_keywords
get_keyword(:parent)
end
def wp_priority_from_keywords
lookup_case_insensitive_key(IssuePriority, :priority)
end
def wp_category_from_keywords(work_package)
lookup_case_insensitive_key(work_package.project.categories, :category)
end
def wp_accountable_from_keywords(work_package)
get_assignable_principal_from_keywords(:responsible, work_package)
end
def wp_assignee_from_keywords(work_package)
get_assignable_principal_from_keywords(:assigned_to, work_package)
end
def get_assignable_principal_from_keywords(keyword, work_package)
keyword = get_keyword(keyword, override: true)
return nil if keyword.blank?
Principal.possible_assignee(work_package.project).where(id: Principal.like(keyword)).first.try(:id)
end
def wp_version_from_keywords(work_package)
lookup_case_insensitive_key(work_package.project.shared_versions, :version, Arel.sql("#{Version.table_name}.name"))
end
def wp_start_date_from_keywords
get_keyword(:start_date, override: true, format: '\d{4}-\d{2}-\d{2}')
end
def wp_due_date_from_keywords
get_keyword(:due_date, override: true, format: '\d{4}-\d{2}-\d{2}')
end
def wp_estimated_hours_from_keywords
get_keyword(:estimated_hours, override: true)
end
def wp_remaining_hours_from_keywords
get_keyword(:remaining_hours, override: true)
end
def log(message, level = :info, report: true)
logs << "#{level}: #{message}" if report
message = "MailHandler: #{message}"
logger.public_send(level, message)
end
def report_errors
return if logs.empty?
UserMailer.incoming_email_error(user, mail_as_hash(email), logs).deliver_later
end
def mail_as_hash(email)
{
message_id: email.message_id,
subject: email.subject,
from: email.from&.first || "(unknown from address)",
quote: incoming_email_quote(email),
text: plain_text_body || incoming_email_text(email)
}
end
def incoming_email_text(mail)
mail.text_part.present? ? mail.text_part.body.to_s : mail.body.to_s
end
def incoming_email_quote(mail)
quote = incoming_email_text(mail)
quoted = String(quote).lines.join("> ")
"> #{quoted}"
end
def work_package_create_contract_class
if options[:no_permission_check]
CreateWorkPackageWithoutAuthorizationsContract
else
WorkPackages::CreateContract
end
end
def work_package_update_contract_class
if options[:no_permission_check]
UpdateWorkPackageWithoutAuthorizationsContract
else
WorkPackages::UpdateContract
end
end
class UpdateWorkPackageWithoutAuthorizationsContract < WorkPackages::UpdateContract
include WorkPackages::SkipAuthorizationChecks
end
class CreateWorkPackageWithoutAuthorizationsContract < WorkPackages::CreateContract
include WorkPackages::SkipAuthorizationChecks
end
end
-100
View File
@@ -1,100 +0,0 @@
# OpenProject is an open source project management software.
# Copyright (C) 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.
class MailHandler::UserCreator
class << self
# Creates a user account for the +email+ sender
def create_user_from_email(email)
addr, name = extract_addr_and_name_from_email(email)
if addr.present?
user = new_user_from_attributes(addr, name)
password = user.password
if user.save
[user, password]
else
logger.error "failed to create User: #{user.errors.full_messages}"
nil
end
else
logger.error "failed to create User: no FROM address found"
nil
end
end
private
# Returns a User from an email address and a full name
def new_user_from_attributes(email_address, fullname = nil)
call = Users::SetAttributesService
.new(user: User.system, model: User.new, contract_class: Users::CreateContract)
.call(**user_initialization_attributes(email_address, fullname))
user = call.result
user.random_password!
assign_fallback_attributes(user, call.errors)
user
end
def user_initialization_attributes(email_address, fullname = nil)
names = fullname.blank? ? email_address.gsub(/@.*\z/, "").split(".") : fullname.split
firstname = names.shift
lastname = names.join(" ")
lastname = "-" if lastname.blank?
{
mail: email_address,
login: email_address,
firstname:,
lastname:
}
end
def assign_fallback_attributes(user, errors)
if errors.any?
user.login = "user#{SecureRandom.hex(6)}" if errors[:login].present?
user.firstname = "-" if errors[:firstname].present?
user.lastname = "-" if errors[:lastname].present?
end
end
def extract_addr_and_name_from_email(email)
from = email.header["from"].to_s
addr = from
name = nil
if m = from.match(/\A"?(.+?)"?\s+<(.+@.+)>\z/)
addr = m[2]
name = m[1]
end
[addr, name]
end
end
end
+2 -2
View File
@@ -68,10 +68,10 @@ module Projects::Versions
def assignable_versions(only_open: true)
if only_open
@assignable_versions ||=
shared_versions.references(:project).with_status_open.order_by_semver_name.to_a
shared_versions.references(:project).with_status_open.order(:name).to_a
else
@assignable_versions_including_non_open ||= # rubocop:disable Naming/MemoizedInstanceVariableName
shared_versions.references(:project).order_by_semver_name.to_a
shared_versions.references(:project).order(:name).to_a
end
end
end
@@ -1,3 +1,5 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -30,16 +32,6 @@ class Queries::Versions::Orders::DefaultOrder < Queries::Orders::Base
self.model = Version
def self.key
/\A(id|name|semver_name)\z/
end
def initialize(attribute)
if attribute == :semver_name
OpenProject::Deprecation.warn("Sorting by semver_name is deprecated, name should be used instead")
super(:name)
else
super
end
/\A(id|name)\z/
end
end
@@ -1,3 +1,5 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -116,11 +118,13 @@ class Queries::WorkPackages::Selects::PropertySelect < Queries::WorkPackages::Se
sortable: false,
summable: false
},
done_ratio: {
done_ratio_for_weighted_average: {
if: -> { WorkPackage.work_weighted_average_mode? },
name: :done_ratio,
sortable: "#{WorkPackage.table_name}.done_ratio",
groupable: true,
summable: true,
summable_select: <<~SQL.squish
summable_select: <<~SQL.squish,
CASE
WHEN estimated_hours IS NULL OR remaining_hours IS NULL OR estimated_hours <= 0 THEN NULL
WHEN remaining_hours <= 0 THEN 100
@@ -130,6 +134,25 @@ class Queries::WorkPackages::Selects::PropertySelect < Queries::WorkPackages::Se
ELSE ROUND( ((1 - (remaining_hours / estimated_hours)) * 100)::numeric )::integer
END as done_ratio
SQL
summable_work_packages_select: false
},
done_ratio_for_simple_average: {
if: -> { WorkPackage.simple_average_mode? },
name: :done_ratio,
sortable: "#{WorkPackage.table_name}.done_ratio",
groupable: true,
summable: true,
summable_select: <<~SQL.squish,
CASE
WHEN done_ratio_count = 0 THEN NULL
WHEN done_ratio >= done_ratio_count * 100 THEN 100
WHEN done_ratio >= done_ratio_count * 99 THEN 99
WHEN done_ratio <= 0 THEN 0
WHEN done_ratio <= done_ratio_count THEN 1
ELSE ROUND(done_ratio::numeric / done_ratio_count)::integer
END as done_ratio
SQL
summable_work_packages_count_select: true
},
created_at: {
sortable: "#{WorkPackage.table_name}.created_at",
@@ -145,8 +168,13 @@ class Queries::WorkPackages::Selects::PropertySelect < Queries::WorkPackages::Se
}
def self.instances(_context = nil)
property_selects.filter_map do |name, options|
new(name, options)
active_selects = property_selects.reject do |_, options|
condition = options[:if]
condition && !condition.call
end
active_selects.filter_map do |default_name, options|
name = options[:name] || default_name
new(name, options.without(:if, :name))
end
end
end
@@ -1,3 +1,5 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -38,6 +40,7 @@ class Queries::WorkPackages::Selects::WorkPackageSelect
:association,
:null_handling,
:summable_select,
:summable_work_packages_count_select,
:summable_work_packages_select
def self.instances(_context = nil)
@@ -74,7 +77,7 @@ class Queries::WorkPackages::Selects::WorkPackageSelect
end
def displayable
@displayable.nil? ? true : @displayable
@displayable.nil? || @displayable
end
def sortable
@@ -128,6 +131,7 @@ class Queries::WorkPackages::Selects::WorkPackageSelect
groupable_join
summable
summable_select
summable_work_packages_count_select
summable_work_packages_select
association
group_by_column_name
+9 -1
View File
@@ -1,3 +1,5 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -117,13 +119,19 @@ module ::Query::Results::Sums
[]
end
group_statement + summed_columns
group_statement + summed_columns + counted_summed_columns
end
def summed_columns
query.summed_up_columns.filter_map(&:summable_work_packages_select).map { |c| "SUM(#{c}) #{c}" }
end
def counted_summed_columns
query.summed_up_columns
.select(&:summable_work_packages_count_select)
.map { |col| "COUNT(#{col.name}) #{col.name}_count" }
end
def callable_summed_up_columns
query.summed_up_columns.select { |column| column.summable.respond_to?(:call) }
end
+3
View File
@@ -35,4 +35,7 @@ class RemoteIdentity < ApplicationRecord
validates :user, uniqueness: { scope: %i[auth_source integration] }
validates :origin_user_id, :user, :auth_source, :integration, presence: true
# FIXME: This needs a better name - 2025.03.18 @mereghost
scope :of_user_and_client, ->(user, client, integration) { find_by(user:, auth_source: client, integration:) }
end
+4
View File
@@ -317,6 +317,10 @@ class User < Principal
authentication_provider&.display_name
end
def provided_by_oidc?
authentication_provider.is_a?(OpenIDConnect::Provider)
end
##
# Allows the API and other sources to determine locking actions
# on represented collections of children of Principals.
+18 -6
View File
@@ -46,25 +46,37 @@ class Version < ApplicationRecord
validates :status, inclusion: { in: VERSION_STATUSES }
validate :validate_start_date_before_effective_date
scopes :order_by_semver_name,
:rolled_up,
scopes :rolled_up,
:shared_with
# Returns versions that are either:
# - from projects the user can see (via :view_work_packages)
# - systemwide versions
# - or referenced by a work package visible to the user (e.g., via sharing)
scope :visible, ->(*args) {
user = args.first || User.current
joins(:project)
.merge(Project.allowed_to(args.first || User.current, :view_work_packages))
.merge(Project.allowed_to(user, :view_work_packages))
.or(Version.systemwide)
.or(Version.shared_via_work_packages(user))
}
scope :systemwide, -> { where(sharing: "system") }
scope :shared_via_work_packages, ->(*args) {
user = args.first || User.current
where(id: WorkPackage.visible(user).where.not(version_id: nil).distinct.select(:version_id))
}
def self.with_status_open
where(status: "open")
end
# Returns true if +user+ or current user is allowed to view the version
def visible?(user = User.current)
user.allowed_in_project?(:view_work_packages, project)
systemwide? ||
user.allowed_in_project?(:view_work_packages, project) ||
work_packages.visible(user).exists?
end
def due_date
@@ -81,8 +93,8 @@ class Version < ApplicationRecord
def spent_hours
@spent_hours ||= TimeEntry
.not_ongoing
.includes(:work_package)
.where(work_packages: { version_id: id })
.joins("INNER JOIN work_packages ON entity_type = 'WorkPackage' AND work_packages.id = entity_id")
.where(work_packages: { version_id: id }, entity_type: "WorkPackage")
.sum(:hours)
.to_f
end
+11 -8
View File
@@ -59,10 +59,8 @@ class WorkPackage < ApplicationRecord
belongs_to :priority, class_name: "IssuePriority"
belongs_to :category, class_name: "Category", optional: true
has_many :time_entries, dependent: :delete_all
has_many :time_entries, dependent: :delete_all, inverse_of: :entity, as: :entity
has_many :file_links, dependent: :delete_all, class_name: "Storages::FileLink", as: :container
has_many :storages, through: :project
has_and_belongs_to_many :changesets, -> { # rubocop:disable Rails/HasAndBelongsToMany
@@ -215,6 +213,14 @@ class WorkPackage < ApplicationRecord
Setting.work_package_done_ratio == "field"
end
def self.work_weighted_average_mode?
Setting.total_percent_complete_mode == "work_weighted_average"
end
def self.simple_average_mode?
Setting.total_percent_complete_mode == "simple_average"
end
def self.complete_on_status_closed?
Setting.percent_complete_on_status_closed == "set_100p"
end
@@ -243,10 +249,7 @@ class WorkPackage < ApplicationRecord
end
def add_time_entry(attributes = {})
attributes.reverse_merge!(
project:,
work_package: self
)
attributes.reverse_merge!(project:, entity: self)
time_entries.build(attributes)
end
@@ -269,7 +272,7 @@ class WorkPackage < ApplicationRecord
end
def to_s
"#{type.is_standard ? '' : type.name} ##{id}: #{subject}"
"#{type.name unless type.is_standard} ##{id}: #{subject}"
end
# Return true if the work_package is closed, otherwise false
@@ -56,7 +56,7 @@ module WorkPackage::TimeEntriesCleaner
end
def update_time_entries(work_packages, action)
TimeEntry.where(["work_package_id IN (?)", work_packages.map(&:id)]).update_all(action)
TimeEntry.where(["entity_type = ? AND entity_id IN (?)", "WorkPackage", work_packages.map(&:id)]).update_all(action)
end
def reassign_time_entries_before_destruction_of(work_packages, user, ids)
@@ -73,7 +73,7 @@ module WorkPackage::TimeEntriesCleaner
false
else
condition = "work_package_id = #{reassign_to.id}, project_id = #{reassign_to.project_id}"
condition = "entity_type = 'WorkPackage', entity_id = #{reassign_to.id}, project_id = #{reassign_to.project_id}"
WorkPackage.update_time_entries(work_packages, condition)
end
end
+5 -5
View File
@@ -31,7 +31,7 @@ module WorkPackages::Costs
included do
belongs_to :budget, inverse_of: :work_packages, optional: true
has_many :cost_entries, dependent: :delete_all
has_many :cost_entries, dependent: :delete_all, inverse_of: :entity, as: :entity
# disabled for now, implements part of ticket blocking
validate :validate_budget
@@ -82,7 +82,7 @@ module WorkPackages::Costs
return unless saved_change_to_project_id?
CostEntry
.where(work_package_id: id)
.where(entity: self)
.update_all(project_id:)
end
end
@@ -125,13 +125,13 @@ module WorkPackages::Costs
false
else
condition = "work_package_id = #{reassign_to.id}, project_id = #{reassign_to.project_id}"
::WorkPackage.update_cost_entries(work_packages.map(&:id), condition)
condition = "entity_type='WorkPackage', entity_id = #{reassign_to.id}, project_id = #{reassign_to.project_id}"
::WorkPackage.update_cost_entries(work_packages, condition)
end
end
def update_cost_entries(work_packages, action)
CostEntry.where(work_package_id: work_packages).update_all(action)
CostEntry.where(entity: work_packages).update_all(action)
end
end
end
@@ -49,11 +49,17 @@ module WorkPackages::Scopes::IncludeSpentTime
def join_visible_time_entries
wp_table
.outer_join(visible_time_entries_cte)
.on(visible_time_entries_cte[:work_package_id].eq(wp_descendants[:id]))
.on(entity_join)
end
def allowed_to_view_time_entries(user)
TimeEntry.not_ongoing.visible(user).select(:id, :work_package_id, :hours).arel
TimeEntry.not_ongoing.visible(user).select(:id, :entity_type, :entity_id, :hours).arel
end
def entity_join
visible_time_entries_cte[:entity_type].eq("WorkPackage").and(
visible_time_entries_cte[:entity_id].eq(wp_descendants[:id])
)
end
def wp_table
-192
View File
@@ -1,192 +0,0 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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.
#++
class BaseTypeService
include Shared::BlockService
include Contracted
attr_accessor :contract_class, :type, :user
def initialize(type, user)
self.type = type
self.user = user
self.contract_class = ::Types::BaseContract
end
def call(params, options, &)
result = update(params, options)
block_with_result(result, &)
end
private
def update(params, options)
success = false
errors = type.errors
Type.transaction do
success, errors = set_params_and_validate(params)
if success
after_type_save(params, options)
else
raise(ActiveRecord::Rollback)
end
end
ServiceResult.new(success:,
errors:,
result: type)
rescue StandardError => e
ServiceResult.failure.tap do |result|
result.errors.add(:base, e.message)
end
end
def set_params_and_validate(params)
# Only set attribute groups when it exists
# (Regression #28400)
set_attribute_groups(params) if params[:attribute_groups]
# This should go before `set_scalar_params` call to get the
# project_ids, custom_field_ids diffs from the type and the params.
# For determining the active custom fields for the type, it is necessary
# to know whether the type is a milestone or not.
set_milestone_param(params) unless params[:is_milestone].nil?
set_active_custom_fields
set_active_custom_fields_for_project_ids(params[:project_ids]) if params[:project_ids].present?
set_scalar_params(params)
validate_and_save(type, user)
end
def set_milestone_param(params)
type.is_milestone = params[:is_milestone]
end
def set_scalar_params(params)
type.attributes = params.except(:attribute_groups, :subject_configuration, :subject_pattern)
end
def set_attribute_groups(params)
if params[:attribute_groups].empty?
type.reset_attribute_groups
else
type.attribute_groups = parse_attribute_groups_params(params)
end
end
def parse_attribute_groups_params(params)
return if params[:attribute_groups].nil?
transform_attribute_groups(params[:attribute_groups])
end
def after_type_save(_params, _options)
# noop to be overwritten by subclasses
end
def transform_attribute_groups(groups)
groups.map do |group|
if group["type"] == "query"
transform_query_group(group)
else
transform_attribute_group(group)
end
end
end
def transform_attribute_group(group)
name =
if group["key"]
group["key"].to_sym
else
group["name"]
end
[
name,
group["attributes"].pluck("key")
]
end
def transform_query_group(group)
name = group["name"]
props = JSON.parse group["query"]
query = Query.new_default(name: "Embedded table: #{name}")
query.extend(OpenProject::ChangedBySystem)
query.change_by_system do
query.user = User.system
end
::API::V3::UpdateQueryFromV3ParamsService
.new(query, user)
.call(props.with_indifferent_access)
query.show_hierarchies = false
[
name,
[query]
]
end
##
# Syncs attribute group settings for custom fields with enabled custom fields
# for this type. If a custom field is not in a group, it is removed from the
# custom_field_ids list.
def set_active_custom_fields
type.custom_field_ids = type
.attribute_groups
.flat_map(&:members)
.select { CustomField.custom_field_attribute? it }
.map { it.gsub(/^custom_field_/, "").to_i }
.uniq
end
def set_active_custom_fields_for_project_ids(project_ids)
new_project_ids_to_activate_cfs = project_ids.reject(&:empty?).map(&:to_i) - type.project_ids
values = Project
.where(id: new_project_ids_to_activate_cfs)
.to_a
.product(type.custom_field_ids)
.map { |p, cf_ids| { project_id: p.id, custom_field_id: cf_ids } }
return if values.empty?
CustomFieldsProject.insert_all(values)
end
end
@@ -1,3 +1,5 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -26,14 +28,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module Versions::Scopes
module OrderBySemverName
extend ActiveSupport::Concern
class_methods do
def order_by_semver_name
order :name
end
end
end
module IncomingEmails
class UnauthorizedAction < StandardError; end
class MissingInformation < StandardError; end
end
@@ -0,0 +1,312 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 IncomingEmails
class DispatchService
include ActionView::Helpers::SanitizeHelper
REFERENCES_RE = %r{^<?op\.([a-z_]+)-(\d+)@}
IGNORED_HEADERS = {
"X-Auto-Response-Suppress" => "oof",
"Auto-Submitted" => /\Aauto-/
}.freeze
# Registry for mail handlers
def self.handlers
@handlers ||= [
IncomingEmails::Handlers::MessageReply,
IncomingEmails::Handlers::WorkPackage
]
end
def self.register_handler(handler_class)
handlers.unshift(handler_class)
end
def self.remove_handler(handler_class)
handlers.delete(handler_class)
end
attr_reader :email, :sender_email, :user, :options, :success, :logs
def initialize(email, options:)
@email = email
@options = assign_options(options)
@sender_email = email.from.to_a.first.to_s.strip
@logs = []
end
def call!
return if ignore_mail?
determine_actor
# We only dispatch the action if a user has been accepted or created
dispatch if user.present?
ensure
report_errors if !@success && Setting.report_incoming_email_errors?
end
private
# Dispatches the mail to the most appropriate handler:
# * Iterates through registered handlers to find one that can handle the email
# * We currently choose the first handler that accepts the mail. In the future, this might need to change
# * Uses the references header to determine the target object type and ID
#
# OpenProject includes the necessary references in the References header of outgoing mail (see ApplicationMailer).
# This stretches the standard in that the values do not reference existing mails but it has the advantage of being able
# identify the object the response is destined for without human interference. Email clients will not remove
# entries from the References header but only add to it.
#
# OpenProject also sets the Message-ID header but gateways such as postmark, unless explicitly instructed otherwise,
# will use their own Message-ID and overwrite the provided one. As an email client includes the value thereof
# in the In-Reply-To and in the References header the Message-ID could also have been used.
#
# Relying on the subject of the mail, which had been implemented before, is brittle as it relies on the user not altering
# the subject. Additionally, the subject structure might change, e.g. via localization changes.
def dispatch
call = call_matching_handler
return if call.nil?
@success = call.success?
log_handler_call(call)
call.result
rescue ActiveRecord::RecordInvalid => e
log "could not save record: #{e.message}", :error
rescue MissingInformation => e
log "missing information from #{user}: #{e.message}", :error
rescue UnauthorizedAction
log "unauthorized attempt from #{user}", :error
end
def log_handler_call(call)
log_type = call.message_type == :notice ? :info : call.message_type
log(call.message, log_type) if call.message.present?
end
##
# Find a matching handler to handle the email and call it.
def call_matching_handler
handler = instantiate_matching_handler
if handler.present?
handler.process
else
log "No matching handler found for email #{email.message_id} from #{user}",
:info,
report: false
nil
end
end
##
# Find a matching handler class for the given email headers
def instantiate_matching_handler
reference = object_reference_from_header
# Instantiate the first handler that can handle this email
self
.class
.handlers
.find { |h| h.handles?(email, reference:) }
&.new(email, user:, plain_text_body:, reference:, options:)
end
##
# Determine the actor for the email processing.
# If the user is not found, we will handle using the given unkown_user option
def determine_actor
if sender_email.present?
@user = User.find_by_mail(sender_email) # rubocop:disable Rails/DynamicFindBy
end
# If the user is still not set, we have to deal with an unkonwn user
if user.nil?
handle_unknown_user
end
end
def handle_unknown_user
case options[:unknown_user]
when "accept"
@user = User.anonymous
when "create"
@user, password = UserCreator.create_user_from_email(email)
if @user
log "[#{@user.login}] account created"
UserMailer.account_information(@user, password).deliver_later
else
log "could not create account for [#{sender_email}]", :error
end
else
# Default behaviour, emails from unknown users are ignored
log "ignoring email from unknown user [#{sender_email}]", report: false
end
end
def report_errors
return if logs.empty?
UserMailer.incoming_email_error(user, mail_as_hash(email), logs).deliver_later
end
def mail_as_hash(email)
{
message_id: email.message_id,
subject: email.subject,
from: email.from&.first || "(unknown from address)",
quote: incoming_email_quote(email),
text: plain_text_body || incoming_email_text(email)
}
end
def incoming_email_text(mail)
mail.text_part.present? ? mail.text_part.body.to_s : mail.body.to_s
end
def incoming_email_quote(mail)
quote = incoming_email_text(mail)
quoted = String(quote).lines.join("> ")
"> #{quoted}"
end
def ignore_mail?
mail_from_system? || ignored_by_header? || ignored_user?
end
def mail_from_system?
# Ignore emails received from the application emission address to avoid hell cycles
if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
log "ignoring email from emission address [#{sender_email}]", report: false
# don't report back errors to ourselves
return true
end
false
end
def ignored_by_header?
# Ignore auto generated emails
IGNORED_HEADERS.each do |key, ignored_value|
value = email.header[key]
next if value.blank?
value = value.to_s.downcase
if (ignored_value.is_a?(Regexp) && value.match(ignored_value)) || value == ignored_value
log "ignoring email with #{key}:#{value} header", report: false
# no point reporting back in case of auto-generated emails
return true
end
end
false
end
def ignored_user?
return false if @user.nil?
unless @user.active?
log "ignoring email from non-active user [#{@user.login}]"
true
end
end
# Find a matching object reference in the mail's references header.
# We set this header in outgoing emails to include an encoded reference to the object
def object_reference_from_header
headers = [email.references].flatten.compact
if headers.reverse.detect { |h| h.to_s =~ REFERENCES_RE }
klass = $1
id = $2.to_i
{ klass:, id: }
else
{}
end
end
def log(message, level = :info, report: true)
logs << "#{level}: #{message}" if report
message = "MailHandler: #{message}"
Rails.logger.public_send(level, message)
nil
end
def assign_options(value) # rubocop:disable Metrics/AbcSize
options = value.dup
options[:issue] ||= {}
options[:allow_override] = allow_override_option(options).to_set(&:to_sym)
# Project needs to be overridable if not specified
options[:allow_override] << :project unless options[:issue].has_key?(:project)
# Status overridable by default
options[:allow_override] << :status unless options[:issue].has_key?(:status)
# Version overridable by default
options[:allow_override] << :version unless options[:issue].has_key?(:version)
# Type overridable by default
options[:allow_override] << :type unless options[:issue].has_key?(:type)
# Priority overridable by default
options[:allow_override] << :priority unless options[:issue].has_key?(:priority)
options[:no_permission_check] = ActiveRecord::Type::Boolean.new.cast(options[:no_permission_check])
options
end
def allow_override_option(options)
if options[:allow_override].is_a?(String)
options[:allow_override].split(",").map(&:strip)
else
options[:allow_override] || []
end
end
# Returns the text/plain part of the email
# If not found (eg. HTML-only email), returns the body with tags removed
def plain_text_body
return @plain_text_body unless @plain_text_body.nil?
part = email.text_part || email.html_part || email
@plain_text_body = Redmine::CodesetUtil.to_utf8(part.body.decoded, part.charset)
# strip html tags and remove doctype directive
# Note: In Rails 5, `strip_tags` also encodes HTML entities
@plain_text_body = strip_tags(@plain_text_body.strip)
@plain_text_body = CGI.unescapeHTML(@plain_text_body)
@plain_text_body.sub! %r{^<!DOCTYPE .*$}, ""
@plain_text_body
end
end
end
@@ -0,0 +1,233 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 IncomingEmails::Handlers
class Base
attr_reader :email, :user, :reference, :options, :plain_text_body
def initialize(email, user:, reference:, plain_text_body:, options:)
@reference = reference
@user = user
@options = options
@email = email
@plain_text_body = plain_text_body
end
# Override in subclasses to determine if this handler can process the email
def self.handles?(email, reference:)
raise NotImplementedError, "Subclasses must implement can_handle? method"
end
# Override in subclasses to process the email
def process
raise NotImplementedError, "Subclasses must implement handle method"
end
def cleaned_up_text_body
cleanup_body(plain_text_body)
end
protected
# The receive_* methods have been moved to specific handler classes:
# - MailHandler::WorkPackage for work package related functionality
# - MailHandler::MessageReply for message reply functionality
def add_attachments(container)
return [] if email.attachments.blank?
email
.attachments
.reject { |attachment| ignored_filename?(attachment.filename) }
.filter_map { |attachment| create_attachment(attachment, container) }
end
def ignored_filenames
@ignored_filenames ||= Setting.mail_handler_ignore_filenames.to_s.split(/[\r\n]+/).compact_blank
end
def ignored_filename?(filename)
ignored_filenames.any? do |line|
filename.match? Regexp.escape(line)
end
end
def create_attachment(attachment, container)
file = OpenProject::Files.create_uploaded_file(
name: attachment.filename,
content_type: attachment.mime_type,
content: attachment.decoded,
binary: true
)
call = ::Attachments::CreateService
.new(user:)
.call(container:, filename: attachment.filename, file:)
call.on_failure do
log "Failed to add attachment #{attachment.filename} for [#{sender_email}]: #{call.message}"
end
call.result
end
# Adds To and Cc as watchers of the given object if the sender has the
# appropriate permission
def add_watchers(obj) # rubocop:disable Metrics/AbcSize
if user.allowed_in_project?(:"add_#{obj.class.name.underscore}_watchers", obj.project) ||
user.allowed_in_project?(:"add_#{obj.class.lookup_ancestors.last.name.underscore}_watchers", obj.project)
addresses = [email.to, email.cc].flatten.compact.uniq.map { |a| a.strip.downcase }
unless addresses.empty?
User
.active
.where(["LOWER(mail) IN (?)", addresses])
.find_each do |user|
Services::CreateWatcher
.new(obj, user)
.run
end
# FIXME: somehow the watchable attribute of the new watcher is not set, when the issue is not safed.
# So we fix that here manually
obj.watchers.each do |w|
w.watchable = obj
end
end
end
end
def get_keyword(attr, options = {})
@keywords ||= {}
if @keywords.has_key?(attr)
@keywords[attr]
else
@keywords[attr] = begin
if (options[:override] || self.options[:allow_override].include?(attr)) &&
(v = extract_keyword!(plain_text_body, attr, options[:format]))
v
else
# Return either default or nil
self.options[:issue][attr]
end
end
end
end
# Destructively extracts the value for +attr+ in +text+
# Returns nil if no matching keyword found
def extract_keyword!(text, attr, format)
keys = human_attr_translations(attr)
.compact_blank
.uniq
.map { |k| Regexp.escape(k) }
value = nil
text.gsub!(/^(#{keys.join('|')})[ \t]*:[ \t]*(?<value>#{format || '.+'})\s*$/i) do |_|
value = Regexp.last_match[:value]&.strip
""
end
value
end
def human_attr_translations(attr)
keys = [
attr.to_s,
attr.to_s.humanize
]
[user.language, Setting.default_language].compact_blank.each do |lang|
keys << all_attribute_translations(lang)[attr]
end
keys
end
def all_attribute_translations(lang)
@all_attribute_translations ||= {}
@all_attribute_translations[lang] ||= begin
translations = {}
# Work package attribute translations
I18n.with_locale(lang) do
%i[assigned_to category due_date estimated_hours parent priority
remaining_hours responsible start_date status type version project].each do |attr|
translations[attr] = ::WorkPackage.human_attribute_name(attr)
end
end
translations
end
end
def target_project
# TODO: other ways to specify project:
# * parse the email To field
# * specific project (eg. Setting.mail_handler_target_project)
target = Project.find_by(identifier: get_keyword(:project))
raise IncomingEmails::MissingInformation.new("Unable to determine target project") if target.nil?
target
end
# Returns a Hash of issue custom field values extracted from keywords in the email body
def custom_field_values_from_keywords(customized)
"#{customized.class.name}CustomField".constantize.all.inject({}) do |h, v|
if value = get_keyword(v.name, override: true)
h[v.id.to_s] = v.value_of value
end
h
end
end
def lookup_case_insensitive_key(scope, attribute, column_name = Arel.sql("name"))
if k = get_keyword(attribute)
scope.find_by("lower(#{column_name}) = ?", k.downcase).try(:id)
end
end
# Removes the email body of text after the truncation configurations.
def cleanup_body(body)
delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).compact_blank.map { |s| Regexp.escape(s) }
unless delimiters.empty?
regex = Regexp.new("^[> ]*(#{delimiters.join('|')})\s*[\r\n].*", Regexp::MULTILINE)
body = body.gsub(regex, "")
end
regex_delimiter = Setting.mail_handler_body_delimiter_regex
if regex_delimiter.present?
regex = Regexp.new(regex_delimiter, Regexp::MULTILINE)
body = body.gsub(regex, "")
end
body.strip
end
end
end
@@ -0,0 +1,76 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 IncomingEmails::Handlers
class MessageReply < Base
# Override in subclasses to determine if this handler can process the email
def self.handles?(_email, reference:)
reference[:klass] == "message"
end
# Override in subclasses to process the email
def process
receive_message_reply(reference[:id])
end
private
# Receives a reply to a forum message
def receive_message_reply(message_id)
message = Message.find_by(id: message_id)
if message
message = message.root
if !options[:no_permission_check] && !user.allowed_in_project?(:add_messages, message.project)
raise ::IncomingEmails::UnauthorizedAction
end
if message.locked?
ServiceResult.failure(message: "ignoring reply from [#{sender_email}] to a locked topic",
mesage_type: :warn)
else
create_reply_message(message)
end
end
end
def create_reply_message(root)
reply = Message.new(subject: email.subject.gsub(%r{^.*msg\d+\]}, "").strip,
content: cleaned_up_text_body)
reply.author = user
reply.forum = root.forum
root.children << reply
add_attachments(reply)
reply
ServiceResult.success(result: reply,
message: "Reply added to message ##{root.id}")
end
end
end
@@ -0,0 +1,242 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 IncomingEmails::Handlers
class WorkPackage < Base
# Contract classes for handlers to use
class UpdateWorkPackageWithoutAuthorizationsContract < WorkPackages::UpdateContract
include WorkPackages::SkipAuthorizationChecks
end
class CreateWorkPackageWithoutAuthorizationsContract < WorkPackages::CreateContract
include WorkPackages::SkipAuthorizationChecks
end
def self.handles?(_email, reference:)
# Handle work package replies if there's a references match
# And handle defaults
reference[:klass].nil? || %w[work_package journal].include?(reference[:klass])
end
def process
case reference[:klass]
when "work_package"
receive_work_package_reply(reference[:id])
when "journal"
receive_journal_reply(reference[:id])
else
# Default: create new work package
receive_new_work_package
end
end
private
def receive_journal_reply(journal_id)
journal = Journal.find_by(id: journal_id)
return unless journal
if journal.journable_type == "WorkPackage"
receive_work_package_reply(journal.journable_id)
end
end
# Creates a new work package
def receive_new_work_package
project = target_project
call = create_work_package(project)
call.message =
if call.success?
"work_package created by #{user}"
else
"work_package could not be created by #{user} due to ##{call.errors.full_messages}"
end
call
end
# Adds a note to an existing work package
def receive_work_package_reply(work_package_id) # rubocop:disable Metrics/AbcSize
work_package = ::WorkPackage.find_by(id: work_package_id)
return unless work_package
# ignore CLI-supplied defaults for new work_packages
options[:issue].clear
call = update_work_package(work_package)
if call.success?
ServiceResult.success(
result: call.result.last_journal,
message: "work_package ##{work_package.id} updated by #{user}"
)
else
ServiceResult.failure(
result: call.result,
message: "work_package ##{work_package.id} could not be updated by #{user} due to ##{call.errors.full_messages}"
)
end
end
def create_work_package(project)
work_package = ::WorkPackage.new(project:)
attributes = collect_wp_attributes_from_email_on_create(work_package)
service_call = WorkPackages::CreateService
.new(user: user,
contract_class: work_package_create_contract_class)
.call(**attributes.merge(work_package:).symbolize_keys)
service_call.on_success do
work_package = service_call.result
add_watchers(work_package)
add_attachments(work_package)
end
end
def collect_wp_attributes_from_email_on_create(work_package)
attributes = wp_attributes_from_keywords(work_package)
attributes
.merge("custom_field_values" => custom_field_values_from_keywords(work_package),
"subject" => email.subject.to_s.chomp[0, 255] || "(no subject)",
"description" => cleaned_up_text_body)
end
def update_work_package(work_package)
attributes = collect_wp_attributes_from_email_on_update(work_package)
attributes[:attachment_ids] = work_package.attachment_ids + add_attachments(work_package).map(&:id)
WorkPackages::UpdateService
.new(user: user,
model: work_package,
contract_class: work_package_update_contract_class)
.call(**attributes.symbolize_keys)
end
def collect_wp_attributes_from_email_on_update(work_package)
attributes = wp_attributes_from_keywords(work_package)
attributes
.merge("custom_field_values" => custom_field_values_from_keywords(work_package),
"journal_notes" => cleaned_up_text_body)
end
# Returns a Hash of issue attributes extracted from keywords in the email body
def wp_attributes_from_keywords(work_package)
{
"assigned_to_id" => wp_assignee_from_keywords(work_package),
"category_id" => wp_category_from_keywords(work_package),
"due_date" => wp_due_date_from_keywords,
"estimated_hours" => wp_estimated_hours_from_keywords,
"parent_id" => wp_parent_from_keywords,
"priority_id" => wp_priority_from_keywords,
"remaining_hours" => wp_remaining_hours_from_keywords,
"responsible_id" => wp_accountable_from_keywords(work_package),
"start_date" => wp_start_date_from_keywords,
"status_id" => wp_status_from_keywords,
"type_id" => wp_type_from_keywords(work_package),
"version_id" => wp_version_from_keywords(work_package)
}.compact_blank!
end
def wp_type_from_keywords(work_package)
lookup_case_insensitive_key(work_package.project.types, :type) ||
(work_package.new_record? && work_package.project.types.first.try(:id))
end
def wp_status_from_keywords
lookup_case_insensitive_key(Status, :status)
end
def wp_parent_from_keywords
get_keyword(:parent)
end
def wp_priority_from_keywords
lookup_case_insensitive_key(IssuePriority, :priority)
end
def wp_category_from_keywords(work_package)
lookup_case_insensitive_key(work_package.project.categories, :category)
end
def wp_accountable_from_keywords(work_package)
get_assignable_principal_from_keywords(:responsible, work_package)
end
def wp_assignee_from_keywords(work_package)
get_assignable_principal_from_keywords(:assigned_to, work_package)
end
def get_assignable_principal_from_keywords(keyword, work_package)
keyword = get_keyword(keyword, override: true)
return nil if keyword.blank?
Principal.possible_assignee(work_package.project).where(id: Principal.like(keyword)).first.try(:id)
end
def wp_version_from_keywords(work_package)
lookup_case_insensitive_key(work_package.project.shared_versions, :version, Arel.sql("#{Version.table_name}.name"))
end
def wp_start_date_from_keywords
get_keyword(:start_date, override: true, format: '\d{4}-\d{2}-\d{2}')
end
def wp_due_date_from_keywords
get_keyword(:due_date, override: true, format: '\d{4}-\d{2}-\d{2}')
end
def wp_estimated_hours_from_keywords
get_keyword(:estimated_hours, override: true)
end
def wp_remaining_hours_from_keywords
get_keyword(:remaining_hours, override: true)
end
def work_package_create_contract_class
if options[:no_permission_check]
CreateWorkPackageWithoutAuthorizationsContract
else
WorkPackages::CreateContract
end
end
def work_package_update_contract_class
if options[:no_permission_check]
UpdateWorkPackageWithoutAuthorizationsContract
else
WorkPackages::UpdateContract
end
end
end
end
@@ -0,0 +1,47 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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 IncomingEmails
class MailHandler < ApplicationMailer
##
# Code copied from base class and extended with optional options parameter
# as well as force_encoding support.
def self.receive(input, options = {})
raw_mail = input.dup
raw_mail.force_encoding("ASCII-8BIT") if raw_mail.respond_to?(:force_encoding)
ActiveSupport::Notifications.instrument("receive.action_mailer") do |payload|
mail = Mail.new(raw_mail)
set_payload_for_mail(payload, mail)
::IncomingEmails::DispatchService.new(mail, options:).call!
end
end
end
end
@@ -0,0 +1,104 @@
# frozen_string_literal: true
# OpenProject is an open source project management software.
# Copyright (C) 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 IncomingEmails
class UserCreator
class << self
# Creates a user account for the +email+ sender
def create_user_from_email(email)
addr, name = extract_addr_and_name_from_email(email)
if addr.present?
user = new_user_from_attributes(addr, name)
password = user.password
if user.save
[user, password]
else
logger.error "failed to create User: #{user.errors.full_messages}"
nil
end
else
logger.error "failed to create User: no FROM address found"
nil
end
end
private
# Returns a User from an email address and a full name
def new_user_from_attributes(email_address, fullname = nil)
call = Users::SetAttributesService
.new(user: User.system, model: User.new, contract_class: Users::CreateContract)
.call(**user_initialization_attributes(email_address, fullname))
user = call.result
user.random_password!
assign_fallback_attributes(user, call.errors)
user
end
def user_initialization_attributes(email_address, fullname = nil)
names = fullname.blank? ? email_address.gsub(/@.*\z/, "").split(".") : fullname.split
firstname = names.shift
lastname = names.join(" ")
lastname = "-" if lastname.blank?
{
mail: email_address,
login: email_address,
firstname:,
lastname:
}
end
def assign_fallback_attributes(user, errors)
if errors.any?
user.login = "user#{SecureRandom.hex(6)}" if errors[:login].present?
user.firstname = "-" if errors[:firstname].present?
user.lastname = "-" if errors[:lastname].present?
end
end
def extract_addr_and_name_from_email(email)
from = email.header["from"].to_s
addr = from
name = nil
if m = from.match(/\A"?(.+?)"?\s+<(.+@.+)>\z/)
addr = m[2]
name = m[1]
end
[addr, name]
end
end
end
end
@@ -47,10 +47,11 @@ module RemoteIdentities
def call
if @model.new_record? || @force_update
user_id = @integration.extract_origin_user_id(@token)
return user_id if user_id.failure?
origin_result = @integration.extract_origin_user_id(@token)
@model.origin_user_id = user_id.result
user_id = origin_result.value_or { return ServiceResult.failure(errors: it) }
@model.origin_user_id = user_id
return success unless @model.changed?
return failure unless @model.save
+10 -10
View File
@@ -338,6 +338,16 @@ class ServiceResult
ApplicationRecord.human_attribute_name(*)
end
def message_type
if @message_type
@message_type.to_sym
elsif success?
:notice
else
:error
end
end
private
def initialize_errors(errors, provided_result)
@@ -351,16 +361,6 @@ class ServiceResult
end
end
def message_type
if @message_type
@message_type.to_sym
elsif success?
:notice
else
:error
end
end
def merge_success!(other)
self.success &&= other.success
end
-77
View File
@@ -1,77 +0,0 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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.
#++
class UpdateTypeService < BaseTypeService
def call(params)
# forbid renaming if it is a standard type
if params[:type] && type.is_standard?
params[:type].delete :name
end
super(params, {})
end
private
def set_params_and_validate(params)
# Set patterns includes a data validation before assigning the value to the attribute.
# A validation failure should return a service call failure.
patterns = params[:patterns]
if patterns.present?
validate_enterprise_action(patterns)
set_patterns(patterns)
return [false, type.errors] if type.errors.any?
end
super
end
def validate_enterprise_action(patterns)
change_from_manual_to_generated = !type.patterns.subject&.enabled? && patterns.dig(:subject, :enabled)
action = :work_package_subject_generation
if change_from_manual_to_generated && !EnterpriseToken.allows_to?(action)
type.errors.add(:patterns, :error_enterprise_only, action: action.to_s.titleize)
end
end
def set_patterns(patterns)
WorkPackageTypes::Patterns::Collection
.build(patterns:)
.either(
->(collection) { type.patterns = collection },
->(result) do
result.errors(full: true).messages.each do |message|
type.errors.add(:patterns, message.text)
end
end
)
end
end
@@ -85,7 +85,7 @@ module WorkPackageTypes
def check_projects(params)
return unless params.key?(:project_ids)
invalid_project_ids = params[:project_ids].reject { |id| Project.exists?(id) }
invalid_project_ids = params[:project_ids].reject { |id| id.blank? || Project.exists?(id) }
unless invalid_project_ids.empty?
@param_validations.update({ project_ids: "Projects with ids #{invalid_project_ids.join(', ')} do not exist." })
end
@@ -126,10 +126,9 @@ class WorkPackages::UpdateAncestorsService < BaseServices::BaseCallable
def compute_derived_done_ratio(work_package, loader)
return if no_children?(work_package, loader)
case Setting.total_percent_complete_mode
when "work_weighted_average"
if WorkPackage.work_weighted_average_mode?
calculate_work_weighted_average_percent_complete(work_package)
when "simple_average"
elsif WorkPackage.simple_average_mode?
calculate_simple_average_percent_complete(work_package, loader)
end
end
+1 -1
View File
@@ -31,7 +31,7 @@
<%= render "common/favicons" %>
<%# Allow gon output when necessary %>
<%= include_gon(nonce: content_security_policy_script_nonce) %>
<%= include_gon(nonce: content_security_policy_nonce) %>
<%# Include CLI assets (development) or prod build assets %>
<%= include_frontend_assets %>
@@ -96,7 +96,7 @@ See COPYRIGHT and LICENSE files for more details.
</td>
<td>
<% if current_user.admin? %>
<% type_links = custom_field.types.map { |t| link_to(t.name, edit_tab_type_path(id: t.id, tab: "form_configuration")) } %>
<% type_links = custom_field.types.map { |t| link_to(t.name, edit_type_form_configuration_path(t)) } %>
<%= safe_join type_links, ", " %>
<% else %>
<%= custom_field.types.map(&:name).join(", ") %>
-170
View File
@@ -1,170 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 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.
++#%>
<% html_title t(:label_administration), t("label_type_plural") %>
<%=
render Primer::OpenProject::PageHeader.new do |header|
header.with_title { t(:label_type_plural) }
header.with_breadcrumbs(
[{ href: admin_index_path, text: t("label_administration") },
{ href: admin_settings_work_packages_general_path, text: t(:label_work_package_plural) },
t(:label_type_plural)]
)
end
%>
<%=
render(Primer::OpenProject::SubHeader.new) do |subheader|
subheader.with_action_button(
scheme: :primary,
leading_icon: :plus,
label: t(:label_type_new),
test_selector: "op-admin-types--button-new",
tag: :a,
href: new_type_path
) do
t("activerecord.attributes.work_package.type")
end
end
%>
<% if @types.any? %>
<div class="generic-table--container">
<div class="generic-table--results-container">
<table class="generic-table" data-controller="table-highlighting">
<colgroup>
<col>
<col data-highlight="false">
<col>
<col>
<col>
<col>
<col>
<col data-highlight="false">
</colgroup>
<thead>
<tr>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= Type.human_attribute_name(:name) %>
</span>
</div>
</div>
</th>
<th><div class="generic-table--empty-header"></div></th>
<!-- Missing workflow warning -->
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= Type.human_attribute_name(:color) %>
</span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= t(:label_active_in_new_projects) %>
</span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= Type.human_attribute_name(:is_milestone) %>
</span>
</div>
</div>
</th>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= t(:button_sort) %>
</span>
</div>
</div>
</th>
<th><div class="generic-table--empty-header"></div></th>
</tr>
</thead>
<tbody>
<% for type in @types %>
<tr>
<td class="timelines-pet-name">
<%= link_to type.name, edit_type_settings_path(type_id: type.id) %>
</td>
<td>
<% if type.workflows.empty? %>
<span class="icon-context icon-warning">
<%= t(:text_type_no_workflow) %>
(<%= link_to t(:button_edit), { controller: "/workflows",
action: "edit",
type_id: type } %>)
</span>
<% end %>
</td>
<td class="timelines-pet-color">
<%= icon_for_type type %>
</td>
<td class="timelines-pet-is_default">
<%= checked_image(type.is_default) %>
</td>
<td class="timelines-pet-is_milestone">
<%= checked_image(type.is_milestone) %>
</td>
<td class="timelines-pet-reorder">
<%= reorder_links("type", { action: "move", id: type }) %>
</td>
<td class="buttons">
<% if !type.is_standard? %>
<%= link_to "", type,
method: :delete,
data: { confirm: t(:text_are_you_sure) },
class: "icon icon-delete",
title: t(:button_delete) %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<%= pagination_links_full @types %>
<% else %>
<%= no_results_box(action_url: new_type_path, display_action: true) %>
<% end %>
@@ -29,24 +29,5 @@ See COPYRIGHT and LICENSE files for more details.
<% html_title t(:label_administration), "#{t(:label_edit)} #{t(:label_work_package_types)} #{h @type.name}" %>
<% tabs = types_tabs %>
<%= render ::Types::EditPageHeaderComponent.new(type: @type, tabs:) %>
<%=
selected_tab = selected_tab(tabs)
form_data = {
subject_configuration_form_data: params[:types_forms_subject_configuration_form_model]
}
if selected_tab.key?(:view_component)
render selected_tab[:view_component].new(@type, **form_data)
else
form_for @type,
url: update_tab_type_path(id: @type.id, tab: @tab),
builder: TabularFormBuilder,
lang: current_language do |f|
render partial: "common/tabs", locals: { f:, tabs:, selected_tab:, with_tab_nav: false }
end
end
%>
<%= render ::Types::EditPageHeaderComponent.new(type: @type, tabs: types_tabs) %>
<%= render WorkPackageTypes::ExportConfigurationComponent.new(@type) %>
@@ -0,0 +1,64 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 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.
++#%>
<% html_title t(:label_administration), t("label_type_plural") %>
<%=
render Primer::OpenProject::PageHeader.new do |header|
header.with_title { t(:label_type_plural) }
header.with_breadcrumbs(
[{ href: admin_index_path, text: t("label_administration") },
{ href: admin_settings_work_packages_general_path, text: t(:label_work_package_plural) },
t(:label_type_plural)]
)
end
%>
<%=
render(Primer::OpenProject::SubHeader.new) do |subheader|
subheader.with_action_button(
scheme: :primary,
leading_icon: :plus,
label: t(:label_type_new),
test_selector: "op-admin-types--button-new",
tag: :a,
href: new_type_path
) do
t("activerecord.attributes.work_package.type")
end
end
%>
<% if @types.any? %>
<%= render WorkPackageTypes::Types::TableComponent.new(rows: @types) %>
<% else %>
<%= no_results_box(
action_url: new_type_path,
display_action: true,
custom_action_text: t("types.index.no_results_content_text")) %>
<% end %>
+1 -1
View File
@@ -141,7 +141,7 @@ See COPYRIGHT and LICENSE files for more details.
<%= styled_select_tag(
"work_package[version_id]",
content_tag("option", t(:label_none), value: "none") +
version_options_for_select(@project.shared_versions.with_status_open.order_by_semver_name),
version_options_for_select(@project.shared_versions.with_status_open.order(:name)),
include_blank: t(:label_no_change_option)
) %>
</div>
@@ -1,11 +1,15 @@
<%=
render(
WorkPackages::DatePicker::DialogContentComponent.new(
work_package:,
schedule_manually:,
focused_field:,
triggering_field: params[:field].underscore.to_sym,
date_mode: params[:date_mode]
content_tag("turbo-frame", id: "wp-datepicker-dialog--content") do
render(
WorkPackages::DatePicker::DialogContentComponent.new(
work_package:,
schedule_manually:,
focused_field:,
triggering_field: params[:triggering_field] || params[:field]&.underscore&.to_sym,
touched_field_map:,
date_mode:,
preview:
)
)
)
end
%>
@@ -28,8 +28,6 @@
module Cron
class ClearOldSessionsJob < ApplicationJob
include ::RakeJob
def perform
Sessions::ClearOldSessionsService.call!
end
+3 -3
View File
@@ -28,10 +28,10 @@
module Cron
class ClearTmpCacheJob < ApplicationJob
include ::RakeJob
##
# Clear the tmp/cache directory in case a file caching is configured
def perform
super("tmp:cache:clear")
FileUtils.rm_rf(Dir["tmp/cache/[^.]*"], verbose: false)
end
end
end
+1 -3
View File
@@ -28,10 +28,8 @@
module Cron
class ClearUploadedFilesJob < ApplicationJob
include ::RakeJob
def perform
super("attachments:clear")
Attachment.clean_cached_files!
end
end
end
+2 -3
View File
@@ -28,12 +28,11 @@
module OAuth
class CleanupJob < ApplicationJob
include ::RakeJob
queue_with_priority :low
def perform
super("doorkeeper:db:cleanup")
cleaner = Doorkeeper::StaleRecordsCleaner.new(Doorkeeper.config.access_token_model)
cleaner.clean_revoked
end
end
end
-69
View File
@@ -1,69 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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.
#++
require "rake"
##
# Invoke a rake task while loading the tasks on demand
# to ensure they are only loaded once.
module RakeJob
attr_reader :task_name, :args
def perform(task_name, *args)
@task_name = task_name
@args = args
Rails.logger.info { "Invoking Rake task #{task_name}." }
invoke
end
protected
def invoke
if (task = load_task)
task.reenable
task.invoke *args
else
OpenProject.logger.error { "Rake task #{task_name} not found for background job." }
end
end
##
# Load tasks if we don't find our task
def load_task
Rails.application.load_tasks unless task_loaded?
task_loaded? && Rake::Task[task_name]
end
##
# Returns whether any task is loaded
# Will raise NameError or NoMethodError depending on what of rake is (not) loaded
def task_loaded?
Rake::Task.task_defined?(task_name)
end
end
@@ -185,9 +185,9 @@ module WorkPackages::Progress::SqlCommands
# packages having children.
def update_totals
update_work_and_remaining_work_totals
if Setting.total_percent_complete_mode == "work_weighted_average"
if WorkPackage.work_weighted_average_mode?
update_total_percent_complete_in_work_weighted_average_mode
elsif Setting.total_percent_complete_mode == "simple_average"
elsif WorkPackage.simple_average_mode?
update_total_percent_complete_in_simple_average_mode
end
end
-3
View File
@@ -125,9 +125,6 @@ module OpenProject
# http://stackoverflow.com/questions/4590229
config.middleware.use Rack::TempfileReaper
# Move secure_headers middleware to after the ShowExceptions
config.middleware.move_after ActionDispatch::ShowExceptions, SecureHeaders::Middleware
# Add lookbook preview paths when enabled
if OpenProject::Configuration.lookbook_enabled?
config.paths.add Primer::ViewComponents::Engine.root.join("app/components").to_s, eager_load: true
+108 -19
View File
@@ -1,25 +1,114 @@
# frozen_string_literal: true
# Be sure to restart your server when you modify this file.
# Define an application-wide content security policy.
# See the Securing Rails Applications Guide for more information:
# https://guides.rubyonrails.org/security.html#content-security-policy-header
# Rails.application.configure do
# config.content_security_policy do |policy|
# policy.default_src :self, :https
# policy.font_src :self, :https, :data
# policy.img_src :self, :https, :data
# policy.object_src :none
# policy.script_src :self, :https
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src style-src)
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end
Rails.application.config.after_initialize do
Rails.application.configure do
config.content_security_policy do |policy|
# Valid for assets
assets_src = ["'self'"]
asset_host = OpenProject::Configuration.rails_asset_host
assets_src << asset_host if asset_host.present?
# Valid for iframes
frame_src = %w['self' https://player.vimeo.com https://www.youtube.com] # rubocop:disable Lint/PercentStringArray
frame_src << OpenProject::Configuration[:security_badge_url]
# Default src
default_src = %w('self') # rubocop:disable Lint/PercentStringArray
# Attachment uploaders
default_src += OpenProject::Configuration.remote_storage_hosts
# Chargebee self-service
chargebee_src = ["https://*.chargebee.com"]
assets_src += chargebee_src
frame_src += chargebee_src
default_src += chargebee_src
# Allow requests to CLI in dev mode
connect_src = default_src + [OpenProject::Configuration.enterprise_trial_creation_host]
# Rules for media (e.g. video sources)
media_src = default_src
media_src << asset_host if asset_host.present?
if OpenProject::Configuration.appsignal_frontend_key
connect_src += ["https://appsignal-endpoint.net"]
end
# Allow connections to S3 for BIM
if OpenProject::Configuration.fog_directory.present?
connect_src += [
"#{OpenProject::Configuration.fog_directory}.s3-#{OpenProject::Configuration.fog_credentials[:region]}.amazonaws.com"
]
end
# Add proxy configuration for Angular CLI to csp
if FrontendAssetHelper.assets_proxied?
proxied = ["ws://#{Setting.host_name}", "http://#{Setting.host_name}",
FrontendAssetHelper.cli_proxy.sub("http", "ws"), FrontendAssetHelper.cli_proxy]
connect_src += proxied
assets_src += proxied
media_src += proxied
end
# Allow to extend the script-src in specific situations
script_src = assets_src + %w(js.chargebee.com)
# Allow unsafe-eval for rack-mini-profiler
if Rails.env.development? && ENV.fetch("OPENPROJECT_RACK_PROFILER_ENABLED", false)
script_src += %w('unsafe-eval') # rubocop:disable Lint/PercentStringArray
end
# Allow ANDI bookmarklet to run in development mode
# https://www.ssa.gov/accessibility/andi/help/install.html
if Rails.env.development?
script_src += ["https://www.ssa.gov"]
assets_src += ["https://www.ssa.gov"]
end
form_action = default_src
# Allow test s3 bucket for direct uploads in tests
if Rails.env.test?
connect_src += ["test-bucket.s3.amazonaws.com"]
form_action += ["test-bucket.s3.amazonaws.com"]
end
# Configure CSP directives
policy.default_src(*default_src)
policy.base_uri("'self'")
policy.font_src(*assets_src, "data:", "'self'")
policy.form_action(*form_action)
policy.frame_src(*frame_src, "'self'")
policy.frame_ancestors("'self'")
policy.img_src("*", "data:", "blob:")
policy.script_src(*script_src)
policy.script_src_attr("'none'")
policy.style_src(*assets_src, "'unsafe-inline'")
policy.object_src(OpenProject::Configuration[:security_badge_url])
policy.connect_src(*connect_src)
policy.media_src(*media_src)
end
# Generate session nonces for permitted importmap, inline scripts, and inline styles.
# This handles Turbo integration natively
config.content_security_policy_nonce_generator = lambda do |request|
# Use Turbo nonce if available (for Turbo navigation)
if request.env["HTTP_TURBO_REFERRER"].present? && request.env["HTTP_X_TURBO_NONCE"].present?
request.env["HTTP_X_TURBO_NONCE"]
else
# Generate a new nonce based on session
SecureRandom.base64(16)
end
end
config.content_security_policy_nonce_directives = %w(script-src)
end
end
-45
View File
@@ -22,49 +22,4 @@ Rails.application.configure do
# Show notes first, all other panels next
config.lookbook.preview_inspector.drawer_panels = [:notes, "*"]
config.lookbook.ui_theme = "blue"
SecureHeaders::Configuration.named_append(:lookbook) do
proxied =
if FrontendAssetHelper.assets_proxied?
["ws://#{Setting.host_name}", "http://#{Setting.host_name}",
FrontendAssetHelper.cli_proxy.sub("http", "ws"), FrontendAssetHelper.cli_proxy]
else
[]
end
{
script_src: proxied + %w('unsafe-eval' 'unsafe-inline' 'self'), # rubocop:disable Lint/PercentStringArray
script_src_elem: proxied + %w('unsafe-eval' 'unsafe-inline' 'self'), # rubocop:disable Lint/PercentStringArray
style_src: proxied + %w('self' 'unsafe-inline'), # rubocop:disable Lint/PercentStringArray
style_src_attr: proxied + %w('self' 'unsafe-inline') # rubocop:disable Lint/PercentStringArray
}
end
# rubocop:disable Lint/ConstantDefinitionInBlock
module LookbookCspExtender
extend ActiveSupport::Concern
included do
before_action do
use_content_security_policy_named_append :lookbook
end
end
end
# rubocop:enable Lint/ConstantDefinitionInBlock
Rails.application.reloader.to_prepare do
Lookbook.add_input_type(:octicon, "lookbook/previews/inputs/octicon")
[
Lookbook::ApplicationController,
Lookbook::PreviewController,
Lookbook::PreviewsController,
Lookbook::PageController,
Lookbook::PagesController,
Lookbook::InspectorController,
Lookbook::EmbedsController
].each do |controller|
controller.include LookbookCspExtender
end
end
end
+1 -1
View File
@@ -329,7 +329,7 @@ Redmine::MenuManager.map :admin_menu do |menu|
parent: :admin_work_packages
menu.push :types,
{ controller: "/types" },
{ controller: "/work_package_types/types" },
if: ->(_) { User.current.admin? },
caption: :label_type_plural,
parent: :admin_work_packages
-141
View File
@@ -1,141 +0,0 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 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.
#++
# rubocop:disable Lint/PercentStringArray
Rails.application.config.after_initialize do
SecureHeaders::Configuration.default do |config|
config.cookies = {
secure: true,
httponly: true
}
# Let Rails ActionDispatch::SSL middleware handle the Strict-Transport-Security header
config.hsts = SecureHeaders::OPT_OUT
config.x_frame_options = "SAMEORIGIN"
config.x_content_type_options = "nosniff"
config.x_xss_protection = "1; mode=block"
config.x_permitted_cross_domain_policies = "none"
config.referrer_policy = "origin-when-cross-origin"
# Valid for assets
assets_src = ["'self'"]
asset_host = OpenProject::Configuration.rails_asset_host
assets_src << asset_host if asset_host.present?
# Valid for iframes
frame_src = %w['self' https://player.vimeo.com https://www.youtube.com]
frame_src << OpenProject::Configuration[:security_badge_url]
# Default src
default_src = %w('self')
# Attachment uploaders
default_src += OpenProject::Configuration.remote_storage_hosts
# Chargebee self-service
frame_src += [
"https://js.chargebee.com/",
"#{OpenProject::Configuration.enterprise_chargebee_site}.chargebee.com"
]
default_src << "#{OpenProject::Configuration.enterprise_chargebee_site}.chargebee.com"
# Allow requests to CLI in dev mode
connect_src = default_src + [OpenProject::Configuration.enterprise_trial_creation_host]
# Rules for media (e.g. video sources)
media_src = default_src
media_src << asset_host if asset_host.present?
if OpenProject::Configuration.appsignal_frontend_key
connect_src += ["https://appsignal-endpoint.net"]
end
# Add proxy configuration for Angular CLI to csp
if FrontendAssetHelper.assets_proxied?
proxied = ["ws://#{Setting.host_name}", "http://#{Setting.host_name}",
FrontendAssetHelper.cli_proxy.sub("http", "ws"), FrontendAssetHelper.cli_proxy]
connect_src += proxied
assets_src += proxied
media_src += proxied
end
# Allow to extend the script-src in specific situations
script_src = assets_src + %w(js.chargebee.com)
# Allow unsafe-eval for rack-mini-profiler
if Rails.env.development? && ENV.fetch("OPENPROJECT_RACK_PROFILER_ENABLED", false)
script_src += %w('unsafe-eval')
end
# Allow ANDI bookmarklet to run in development mode
# https://www.ssa.gov/accessibility/andi/help/install.html
if Rails.env.development?
script_src += ["https://www.ssa.gov"]
assets_src += ["https://www.ssa.gov"]
end
config.csp = {
preserve_schemes: true,
# Don't append unsafe-inline in CSP as a fallback
disable_nonce_backwards_compatibility: true,
# Fallback when no value is defined
default_src:,
# Allowed uri in <base> tag
base_uri: %w('self'),
# Allow fonts from self, asset host, or DATA uri
font_src: assets_src + %w(data: 'self'),
# Form targets can only be self
form_action: default_src,
# Allow iframe from vimeo (welcome video)
frame_src: frame_src + %w('self'),
frame_ancestors: %w('self'),
# Allow images from anywhere including data urls and blobs (used in resizing)
img_src: %w(* data: blob:),
# Allow scripts from self
script_src:,
script_src_attr: %w('none'),
# Allow unsafe-inline styles
style_src: assets_src + %w('unsafe-inline'),
# Allow object-src from Release API
object_src: [OpenProject::Configuration[:security_badge_url]],
# Connect sources for CLI in dev mode
connect_src:,
# Allow videos from self and from the asset proxy in dev mode.
media_src:
}
end
end
# rubocop:enable Lint/PercentStringArray
+8 -3
View File
@@ -28,16 +28,21 @@
# See COPYRIGHT and LICENSE files for more details.
#++
# Here we are loading STI models explicitly using rails autoloader.
# It is relevant for environments where lazy loading is enabled (usually, development and testing).
# If an STI model has not been loaded it can lead to undesired behavior like:
# Fetching not loaded yet STI model (ServiceAccount) through its parent model(User)
# (e.g. User.find(service_account_id) raises ActiveRecord::NotFound.
Rails.application.config.to_prepare do
# Load Enumeration descendants due to STI
# Load Enumeration descendants
IssuePriority
# Load Principal descendants due to STI
# Load Principal descendants
User
PlaceholderUser
Group
# Use User descendants due to STI
# Load User descendants
SystemUser
AnonymousUser
Users::InexistentUser
+2 -8
View File
@@ -1242,6 +1242,7 @@ af:
error_unauthorized: "may not be accessed."
error_readonly: "was attempted to be written but is not writable."
error_conflict: "Information has been updated by at least one other user in the meantime."
error_not_found: "not found."
email: "is not a valid email address."
empty: "can't be empty."
enterprise_plan_required: "requires at least the %{plan_name}."
@@ -1401,8 +1402,6 @@ af:
cannot_be_a_non_working_day: "can't be a non-working day."
query:
attributes:
project:
error_not_found: "not found"
public:
error_unauthorized: "- The user has no permission to create public views."
group_by:
@@ -1442,10 +1441,8 @@ af:
circular_dependency: "The relationship creates a circle of relationships."
attributes:
to_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
from_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
repository:
not_available: "SCM vendor is not available"
@@ -1585,10 +1582,6 @@ af:
description: "'Wagwoord bevestiging' moet ooreenstem met die insette in die 'Nuwe wagwoord' gebied."
status:
invalid_on_create: "is not a valid status for new users."
ldap_auth_source:
error_not_found: "not found"
auth_source:
error_not_found: "not found"
member:
principal_blank: "Kies asseblief ten minste een gebruiker of groep."
role_blank: "need to be assigned."
@@ -1798,6 +1791,7 @@ af:
title: "Titel"
type: "Soort"
typeahead: "Autocomplete"
uid: "Unique identifier"
updated_at: "Opgedateer op"
updated_on: "Opgedateer op"
uploader: "Uploader"
+2 -8
View File
@@ -1274,6 +1274,7 @@ ar:
error_unauthorized: "may not be accessed."
error_readonly: "was attempted to be written but is not writable."
error_conflict: "تم تحديث المعلومات من قبل مستخدم واحد آخر على الأقل في الوقت نفسه."
error_not_found: "not found."
email: "is not a valid email address."
empty: "لا يمكن أن تكون فارغة."
enterprise_plan_required: "requires at least the %{plan_name}."
@@ -1433,8 +1434,6 @@ ar:
cannot_be_a_non_working_day: "can't be a non-working day."
query:
attributes:
project:
error_not_found: "لم يتم العثور"
public:
error_unauthorized: "- ليس للمستخدم إذن بإنشاء طرق عرض عامة."
group_by:
@@ -1474,10 +1473,8 @@ ar:
circular_dependency: "The relationship creates a circle of relationships."
attributes:
to_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
from_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
repository:
not_available: "بائع SCM غير متاح"
@@ -1621,10 +1618,6 @@ ar:
description: "'تأكيد كلمة المرور' يجب أن يطابق الإدخال في حقل 'كلمة المرور الجديدة'."
status:
invalid_on_create: "ليس حالة صحيحة للمستخدمين الجدد."
ldap_auth_source:
error_not_found: "لم يتم العثور"
auth_source:
error_not_found: "لم يتم العثور"
member:
principal_blank: "يرجى اختيار مستخدم واحد على الأقل أو مجموعة."
role_blank: "need to be assigned."
@@ -1886,6 +1879,7 @@ ar:
title: "العنوان"
type: "النّوع"
typeahead: "Autocomplete"
uid: "Unique identifier"
updated_at: "تم التحديث بتاريخ"
updated_on: "تم التحديث بتاريخ"
uploader: "Uploader"
+2 -8
View File
@@ -1242,6 +1242,7 @@ az:
error_unauthorized: "may not be accessed."
error_readonly: "was attempted to be written but is not writable."
error_conflict: "Information has been updated by at least one other user in the meantime."
error_not_found: "not found."
email: "is not a valid email address."
empty: "can't be empty."
enterprise_plan_required: "requires at least the %{plan_name}."
@@ -1401,8 +1402,6 @@ az:
cannot_be_a_non_working_day: "can't be a non-working day."
query:
attributes:
project:
error_not_found: "not found"
public:
error_unauthorized: "- The user has no permission to create public views."
group_by:
@@ -1442,10 +1441,8 @@ az:
circular_dependency: "The relationship creates a circle of relationships."
attributes:
to_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
from_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
repository:
not_available: "SCM vendor is not available"
@@ -1585,10 +1582,6 @@ az:
description: "'Password confirmation' should match the input in the 'New password' field."
status:
invalid_on_create: "is not a valid status for new users."
ldap_auth_source:
error_not_found: "not found"
auth_source:
error_not_found: "not found"
member:
principal_blank: "Please choose at least one user or group."
role_blank: "need to be assigned."
@@ -1798,6 +1791,7 @@ az:
title: "Title"
type: "Type"
typeahead: "Autocomplete"
uid: "Unique identifier"
updated_at: "Updated on"
updated_on: "Updated on"
uploader: "Uploader"
+2 -8
View File
@@ -1258,6 +1258,7 @@ be:
error_unauthorized: "may not be accessed."
error_readonly: "was attempted to be written but is not writable."
error_conflict: "Information has been updated by at least one other user in the meantime."
error_not_found: "not found."
email: "is not a valid email address."
empty: "can't be empty."
enterprise_plan_required: "requires at least the %{plan_name}."
@@ -1417,8 +1418,6 @@ be:
cannot_be_a_non_working_day: "can't be a non-working day."
query:
attributes:
project:
error_not_found: "not found"
public:
error_unauthorized: "- The user has no permission to create public views."
group_by:
@@ -1458,10 +1457,8 @@ be:
circular_dependency: "The relationship creates a circle of relationships."
attributes:
to_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
from_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
repository:
not_available: "SCM vendor is not available"
@@ -1603,10 +1600,6 @@ be:
description: "'Password confirmation' should match the input in the 'New password' field."
status:
invalid_on_create: "is not a valid status for new users."
ldap_auth_source:
error_not_found: "not found"
auth_source:
error_not_found: "not found"
member:
principal_blank: "Please choose at least one user or group."
role_blank: "need to be assigned."
@@ -1842,6 +1835,7 @@ be:
title: "Title"
type: "Type"
typeahead: "Autocomplete"
uid: "Unique identifier"
updated_at: "Updated on"
updated_on: "Updated on"
uploader: "Uploader"
+2 -8
View File
@@ -1242,6 +1242,7 @@ bg:
error_unauthorized: "не може да бъде осъществен достъп."
error_readonly: "направен е неуспешен опит за запис"
error_conflict: "Информацията е актуализирана от поне един друг потребител в същото време."
error_not_found: "not found."
email: "е невалидна електронна поща."
empty: "не може да бъде празно."
enterprise_plan_required: "requires at least the %{plan_name}."
@@ -1401,8 +1402,6 @@ bg:
cannot_be_a_non_working_day: "can't be a non-working day."
query:
attributes:
project:
error_not_found: "не е намерен"
public:
error_unauthorized: "- The user has no permission to create public views."
group_by:
@@ -1442,10 +1441,8 @@ bg:
circular_dependency: "The relationship creates a circle of relationships."
attributes:
to_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
from_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
repository:
not_available: "SCM доставчик не е наличен"
@@ -1585,10 +1582,6 @@ bg:
description: "\"Потвърждаване на паролата\" трябва да съответства на въведеното в поле \"нова парола\"."
status:
invalid_on_create: "не е валиден статус за нови потребители."
ldap_auth_source:
error_not_found: "не е намерен"
auth_source:
error_not_found: "не е намерен"
member:
principal_blank: "Моля изберете поне един потребител или група."
role_blank: "е необходимо да бъдат назначени."
@@ -1798,6 +1791,7 @@ bg:
title: "Заглавие"
type: "Тип"
typeahead: "Autocomplete"
uid: "Unique identifier"
updated_at: "Актуализиран на"
updated_on: "Актуализиран на"
uploader: "Uploader"
+2 -8
View File
@@ -1239,6 +1239,7 @@ ca:
error_unauthorized: "no és possible accedir."
error_readonly: "es va intentar d'escriure-hi però no és modificable."
error_conflict: "La informació s'ha actualitzat per almenys un altre usuari mentrestant."
error_not_found: "not found."
email: "no és una adreça de correu electrònic vàlida."
empty: "no pot estar buit."
enterprise_plan_required: "requires at least the %{plan_name}."
@@ -1398,8 +1399,6 @@ ca:
cannot_be_a_non_working_day: "can't be a non-working day."
query:
attributes:
project:
error_not_found: "no trobat"
public:
error_unauthorized: "- L'usuari no té permisos per crear vistes públiques."
group_by:
@@ -1439,10 +1438,8 @@ ca:
circular_dependency: "La relació crea un cercle de relacions."
attributes:
to_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
from_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
repository:
not_available: "El proveïdor SCM no està disponible"
@@ -1582,10 +1579,6 @@ ca:
description: "'Confirmació de la contrasenya' ha de coincidir amb la introduïda al camp 'Nova contrasenya'."
status:
invalid_on_create: "no és un valor vàlid per a nous usuaris."
ldap_auth_source:
error_not_found: "no trobat"
auth_source:
error_not_found: "no trobat"
member:
principal_blank: "Trieu com a mínim un usuari o grup."
role_blank: "s'ha d'assignar."
@@ -1795,6 +1788,7 @@ ca:
title: "Títol"
type: "Classe"
typeahead: "Autocomplete"
uid: "Unique identifier"
updated_at: "Actualitzat el"
updated_on: "Actualitzat el"
uploader: "Eina de càrrega"
+2 -8
View File
@@ -1242,6 +1242,7 @@ ckb-IR:
error_unauthorized: "may not be accessed."
error_readonly: "was attempted to be written but is not writable."
error_conflict: "Information has been updated by at least one other user in the meantime."
error_not_found: "not found."
email: "is not a valid email address."
empty: "can't be empty."
enterprise_plan_required: "requires at least the %{plan_name}."
@@ -1401,8 +1402,6 @@ ckb-IR:
cannot_be_a_non_working_day: "can't be a non-working day."
query:
attributes:
project:
error_not_found: "not found"
public:
error_unauthorized: "- The user has no permission to create public views."
group_by:
@@ -1442,10 +1441,8 @@ ckb-IR:
circular_dependency: "The relationship creates a circle of relationships."
attributes:
to_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
from_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
repository:
not_available: "SCM vendor is not available"
@@ -1585,10 +1582,6 @@ ckb-IR:
description: "'Password confirmation' should match the input in the 'New password' field."
status:
invalid_on_create: "is not a valid status for new users."
ldap_auth_source:
error_not_found: "not found"
auth_source:
error_not_found: "not found"
member:
principal_blank: "Please choose at least one user or group."
role_blank: "need to be assigned."
@@ -1798,6 +1791,7 @@ ckb-IR:
title: "Title"
type: "Type"
typeahead: "Autocomplete"
uid: "Unique identifier"
updated_at: "Updated on"
updated_on: "Updated on"
uploader: "Uploader"
+27 -33
View File
@@ -1045,7 +1045,7 @@ cs:
enabled_modules: "Povolené moduly"
identifier: "Identifikátor"
latest_activity_at: "Poslední aktivita"
parent: "Nadřazený projekt"
parent: "Podprojekt"
public_value:
title: "Viditelnost"
true: "veřejný"
@@ -1258,6 +1258,7 @@ cs:
error_unauthorized: "není přístupný."
error_readonly: "se pokusil být napsán, ale není zapisovatelný."
error_conflict: "Informace mezitím aktualizoval alespoň jeden další uživatel."
error_not_found: "not found."
email: "není platná e-mailová adresa"
empty: "nemůže být prázdné."
enterprise_plan_required: "requires at least the %{plan_name}."
@@ -1371,7 +1372,7 @@ cs:
meeting:
error_conflict: "Nelze uložit, protože schůzku mezitím aktualizoval někdo jiný. Znovu načtěte stránku."
notifications:
at_least_one_channel: "Pro odesílání notifikací musí být specifikován alespoň jeden kanál"
at_least_one_channel: "Alespoň jeden kanál pro odesílání oznámení musí být specifikován."
attributes:
read_ian:
read_on_creation: "nelze nastavit na pravdivé při vytváření oznámení "
@@ -1417,8 +1418,6 @@ cs:
cannot_be_a_non_working_day: "can't be a non-working day."
query:
attributes:
project:
error_not_found: "nenalezeno"
public:
error_unauthorized: "- Uživatel nemá oprávnění k vytváření veřejných zobrazení."
group_by:
@@ -1458,10 +1457,8 @@ cs:
circular_dependency: "Vztah vytvoří cyklický vztah."
attributes:
to_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
from_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
repository:
not_available: "Prodejce SCM není k dispozici"
@@ -1603,10 +1600,6 @@ cs:
description: "\"Potvrzení hesla\" musí odpovídat vstupu v poli \"Nové heslo\"."
status:
invalid_on_create: "není platný stav pro nové uživatele."
ldap_auth_source:
error_not_found: "nenalezeno"
auth_source:
error_not_found: "nenalezeno"
member:
principal_blank: "Zvolte alespoň jednoho uživatele nebo skupinu."
role_blank: "musí být přiřazen."
@@ -1654,11 +1647,11 @@ cs:
member: "Člen"
news: "Novinky"
notification:
one: "Notifikace"
few: "Notifikací"
many: "Notifikací"
other: "Notifikace"
placeholder_user: "Placeholder uživatel"
one: "Oznámení"
few: "Oznámení"
many: "Oznámení"
other: "Oznámení"
placeholder_user: "placeholder uživatel"
project:
one: "Projekt"
few: "Projekty"
@@ -1842,6 +1835,7 @@ cs:
title: "Název"
type: "Typ"
typeahead: "Autocomplete"
uid: "Unique identifier"
updated_at: "Aktualizováno"
updated_on: "Aktualizováno"
uploader: "Uploader"
@@ -2625,7 +2619,7 @@ cs:
instructions_after_error: "Zkuste se znovu přihlásit kliknutím na %{signin}. Pokud chyba přetrvává, požádejte správce o pomoc."
menus:
admin:
mail_notification: "E-mailové notifikace"
mail_notification: "E-mailová upozornění"
mails_and_notifications: "E-maily a oznámení"
aggregation: "Agregace"
api_and_webhooks: "API & Webhooky"
@@ -2687,7 +2681,7 @@ cs:
by_project: "Nepřečteno dle projektu"
by_reason: "Důvod"
inbox: "Doručená pošta"
send_notifications: "Pro tuto akci odeslat notifikaci"
send_notifications: "Odeslat oznámení pro tuto akci"
work_packages:
subject:
created: "Pracovní balíček byl vytvořen."
@@ -3110,9 +3104,9 @@ cs:
label_permissions: "Práva"
label_permissions_report: "Přehled oprávnění"
label_personalize_page: "Přizpůsobit tuto stránku"
label_placeholder_user: "Placeholder uživatel"
label_placeholder_user: "placeholder uživatel"
label_placeholder_user_new: ""
label_placeholder_user_plural: "Placeholder uživatelé"
label_placeholder_user_plural: "placeholder uživatelé"
label_planning: "Plánování"
label_please_login: "Přihlaste se prosím"
label_plugins: "Pluginy"
@@ -3135,7 +3129,7 @@ cs:
label_project_attribute_plural: "Atributy projektu"
label_project_attribute_manage_link: "Správa atributů produktu"
label_project_count: "Celkový počet projektů"
label_project_copy_notifications: "Během kopírování projektu odeslat notifikace e-mailem"
label_project_copy_notifications: "Během kopie projektu odeslat oznámení e-mailem"
label_project_latest: "Nejnovější projekty"
label_project_default_type: "Povolit prázdný typ"
label_project_hierarchy: "Hierarchie projektu"
@@ -3289,7 +3283,7 @@ cs:
label_version_new: "Nová verze"
label_version_edit: "Upravit verzi"
label_version_plural: "Verze"
label_version_sharing_descendants: "S podprojekty"
label_version_sharing_descendants: "S Podprojekty"
label_version_sharing_hierarchy: "S hierarchií projektu"
label_version_sharing_none: "Není sdíleno"
label_version_sharing_system: "Se všemi projekty"
@@ -3395,28 +3389,28 @@ cs:
digests:
including_mention_singular: "včetně zmínky"
including_mention_plural: "včetně %{number_mentioned} zmínění"
unread_notification_singular: "1 nepřečtená notifikace"
unread_notification_plural: "%{number_unread} nepřečtených notifikací"
unread_notification_singular: "1 nepřečtené oznámení"
unread_notification_plural: "%{number_unread} nepřečtených oznámení"
you_have: "Máte"
logo_alt_text: "Logo"
mention:
subject: "%{user_name} vás zmínil v #%{id} - %{subject}"
notification:
center: "Centrum notifikací"
center: "Centrum oznámení"
see_in_center: "Zobrazit komentář v oznamovacím centru"
settings: "Změnit nastavení e-mailu"
salutation: "Ahoj %{user}!"
salutation_full_name: "Jméno a příjmení"
work_packages:
created_at: "Vytvořeno v %{timestamp} uživatelem %{user} "
login_to_see_all: "Přihlaste se pro zobrazení všech notifikací."
login_to_see_all: "Přihlaste se pro zobrazení všech oznámení."
mentioned: "Byli jste <b>zmíněni v komentáři</b>"
mentioned_by: "%{user} vás zmínil v komentáři OpenProject"
more_to_see:
one: "Existuje ještě 1 pracovní balíček s notifikací."
few: "Existuje ještě %{count} pracovních balíčků s notifikacema."
many: "Existuje ještě %{count} pracovních balíčků s notifikacema."
other: "Existuje ještě %{count} pracovních balíčků s notifikacema."
one: "Máte ještě 1 pracovní balíček s notifikací."
few: "Existuje ještě %{count} pracovních balíčků s oznámeními."
many: "Máte ještě %{count} pracovních balíčků s notifikacemi."
other: "Existuje ještě %{count} pracovních balíčků s oznámeními."
open_in_browser: "Otevřít v prohlížeči"
reason:
watched: "Sledováno"
@@ -3425,7 +3419,7 @@ cs:
mentioned: "Zmíněné"
shared: "Sdílené"
subscribed: "vše"
prefix: "Obdrženo z důvodu nastavení notifikací: %{reason}"
prefix: "Obdrženo z důvodu nastavení oznámení: %{reason}"
date_alert_start_date: "Upozornění na datum"
date_alert_due_date: "Upozornění na datum"
reminder: "Připomínka"
@@ -3716,7 +3710,7 @@ cs:
permission_move_work_packages: "Přesun pracovních balíčků"
permission_protect_wiki_pages: "Ochrana stránky wiki"
permission_rename_wiki_pages: "Přejmenovat stránky wiki"
permission_save_queries: "Uložit zobrazení"
permission_save_queries: "Uložit pohled"
permission_search_project: "Hledat projekt"
permission_select_custom_fields: "Vybrat vlastní pole"
permission_select_project_custom_fields: "Vyberte atributy projektu"
@@ -4158,7 +4152,7 @@ cs:
enable_subscriptions_text_html: Umožňuje uživatelům s nezbytnými oprávněními přihlásit se do OpenProject kalendářů a získat přístup k informacím o pracovním balíčku prostřednictvím externího klienta kalendáře. <strong>Poznámka:</strong> Před povolením si prosím přečtěte <a href="%{link}" target="_blank">podrobnosti o odběru</a>.
language_name_being_default: "%{language_name} (výchozí)"
notifications:
events_explanation: "Určuje, pro kterou událost je odeslán e-mail. Pracovní balíčky jsou z tohoto seznamu vyloučeny, protože notifikace pro ně mohou být nastavena speciálně pro každého uživatele."
events_explanation: "Určuje, pro kterou událost je odeslán e-mail. Pracovní balíčky jsou z tohoto seznamu vyloučeny, protože oznámení pro ně mohou být nastavena speciálně pro každého uživatele."
delay_minutes_explanation: "Odesílání e-mailu může být pozdrženo, aby bylo uživatelům s nakonfigurovaným v oznámení aplikace před odesláním pošty potvrzeno oznámení. Uživatelé, kteří si přečtou oznámení v aplikaci, nedostanou e-mail pro již přečtené oznámení."
other: "Ostatní"
passwords: "Hesla"
@@ -4266,7 +4260,7 @@ cs:
text_destroy_with_associated: "Existují další objekty, které jsou přiřazeny k pracovním balíčkům a které mají být odstraněny. Tyto objekty jsou následující typy:"
text_destroy_what_to_do: "Co chcete udělat?"
text_diff_truncated: "... Toto rozlišení bylo zkráceno, protože přesahuje maximální velikost, kterou lze zobrazit."
text_email_delivery_not_configured: "Doručení e-mailu není nakonfigurováno a notifikace jsou zakázány.\nNakonfigurujte váš SMTP server pro jejich povolení."
text_email_delivery_not_configured: "Doručení e-mailu není nakonfigurováno a oznámení jsou zakázána.\nNakonfigurujte váš SMTP server pro jejich povolení."
text_enumeration_category_reassign_to: "Přiřadit je k této hodnotě:"
text_enumeration_destroy_question: "%{count} objektů je přiřazeno k této hodnotě."
text_file_repository_writable: "Do adresáře příloh lze zapisovat"
+2 -8
View File
@@ -1240,6 +1240,7 @@ da:
error_unauthorized: "kan muligvis ikke tilgås."
error_readonly: "was attempted to be written but is not writable."
error_conflict: "Oplysning er opdateret af mindst én anden bruger i mellemtiden."
error_not_found: "not found."
email: "is not a valid email address."
empty: "må ikke være tomt."
enterprise_plan_required: "requires at least the %{plan_name}."
@@ -1399,8 +1400,6 @@ da:
cannot_be_a_non_working_day: "can't be a non-working day."
query:
attributes:
project:
error_not_found: "not found"
public:
error_unauthorized: "- The user has no permission to create public views."
group_by:
@@ -1440,10 +1439,8 @@ da:
circular_dependency: "The relationship creates a circle of relationships."
attributes:
to_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
from_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
repository:
not_available: "SCM-leverandør utilgængelig"
@@ -1583,10 +1580,6 @@ da:
description: "'Adgangskodebekræftelse' skal matche input fra feltet 'Ny adgangskode'."
status:
invalid_on_create: "is not a valid status for new users."
ldap_auth_source:
error_not_found: "not found"
auth_source:
error_not_found: "not found"
member:
principal_blank: "Vælg venligst mindst én bruger eller gruppe."
role_blank: "need to be assigned."
@@ -1796,6 +1789,7 @@ da:
title: "Titel"
type: "Type"
typeahead: "Autocomplete"
uid: "Unique identifier"
updated_at: "Opdateret den"
updated_on: "Opdateret den"
uploader: "Uploader"
+15 -21
View File
@@ -86,11 +86,11 @@ de:
title: "Enterprise-Token hinzufügen"
type_token_text: "Enterprise-Token Text"
token_placeholder: "Enterprise-Token Text hier einfügen"
add_token: "Enterprise-Edition Support Token hochladen"
add_token: "Enterprise edition Support Token hochladen"
replace_token: "Aktuellen Enterprise edition Support Token ersetzen"
order: "Enterprise on-premises bestellen"
paste: "Enterprise-Edition Support Token hier einfügen"
required_for_feature: "Dieses Add-on ist nur mit einem aktiven Enterprise-Edition Support-Token verfügbar."
paste: "Enterprise edition Support Token hier einfügen"
required_for_feature: "Dieses Add-on ist nur mit einem aktiven Enterprise edition Support-Token verfügbar."
enterprise_link: "Klicken Sie hier für weitere Informationen."
start_trial: "Kostenlose Testversion starten"
book_now: "Jetzt buchen"
@@ -840,7 +840,7 @@ de:
label_child_singular: "Unteraufgabe"
label_child_plural: "Unteraufgaben"
new_child: "Neue Unteraufgabe"
new_child_description: "Erstellt ein zugehöriges Arbeitspaket als Unteraufgabe des aktuellen (übergeordneten) Arbeitspakets"
new_child_description: "Erstellt ein zugehöriges Arbeitspaket als Unterelement des aktuellen (übergeordneten) Arbeitspakets"
child: "Unteraufgabe"
child_description: "Macht das zugehörige Arbeitspaket zu einer Unteraufgabe des aktuellen (übergeordneten) Arbeitspakets"
parent: "Übergeordnetes Arbeitspaket"
@@ -1234,6 +1234,7 @@ de:
error_unauthorized: "kann nicht zugegriffen werden."
error_readonly: "wurde versucht zu beschreiben, ist aber nicht beschreibbar."
error_conflict: "Die Informationen wurde zwischenzeitlich durch andere Benutzer geändert."
error_not_found: "nicht gefunden."
email: "ist keine gültige E-Mail-Adresse."
empty: "muss ausgefüllt werden."
enterprise_plan_required: "benötigt mindestens %{plan_name}."
@@ -1393,8 +1394,6 @@ de:
cannot_be_a_non_working_day: "kann kein arbeitsfreier Tag sein."
query:
attributes:
project:
error_not_found: "nicht gefunden"
public:
error_unauthorized: "- Der Nutzer hat nicht die Berechtigung öffentliche Ansichten zu erstellen."
group_by:
@@ -1434,10 +1433,8 @@ de:
circular_dependency: "Die Beziehung erzeugt eine zirkuläre Abhängigkeit."
attributes:
to_id:
error_not_found: "nicht gefunden oder nicht sichtbar."
error_readonly: "kann für bestehende Beziehungen nicht geändert werden."
from_id:
error_not_found: "nicht gefunden oder nicht sichtbar."
error_readonly: "kann für bestehende Beziehungen nicht geändert werden."
repository:
not_available: "SCM-Anbieter ist nicht verfügbar"
@@ -1525,7 +1522,7 @@ de:
status_transition_invalid: "ist ungültig, da kein valider Übergang vom alten zum neuen Status für die aktuelle Rolle des Nutzers existiert."
status_invalid_in_type: "ist ungültig, da der aktuelle Status nicht in diesem Typ vorhanden ist."
type:
cannot_be_milestone_due_to_children: "kann kein Meilenstein werden, da dieses Arbeitspaket Unteraufgaben besitzt."
cannot_be_milestone_due_to_children: "kann kein Meilenstein werden, da dieses Arbeitspaket Unterelemente besitzt."
priority_id:
only_active_priorities_allowed: "muss aktiv sein."
category:
@@ -1577,10 +1574,6 @@ de:
description: "\"Kennwortbestätigung\" sollte mit \"Neues Kennwort\" übereinstimmen."
status:
invalid_on_create: "ist kein gültiger Status für neue Benutzer."
ldap_auth_source:
error_not_found: "nicht gefunden"
auth_source:
error_not_found: "nicht gefunden"
member:
principal_blank: "Bitte wählen Sie mindestens einen Benutzer oder eine Gruppe."
role_blank: "muss zugeordnet werden."
@@ -1637,8 +1630,8 @@ de:
one: "Rolle"
other: "Rollen"
scim_client:
one: "SCIM-Client"
other: "SCIM clients"
one: "SCIM-client"
other: "SCIM-clients"
status: "Arbeitspaket-Status"
token/api:
one: Zugangs-Token
@@ -1790,6 +1783,7 @@ de:
title: "Titel"
type: "Typ"
typeahead: "Autovervollständigung"
uid: "Eindeutige Kennung"
updated_at: "Aktualisiert am"
updated_on: "Aktualisiert am"
uploader: "Bereitgestellt von"
@@ -2262,7 +2256,7 @@ de:
error_custom_option_not_found: "Option ist nicht vorhanden."
error_enterprise_plan_needed: "Sie benötigen den Enterprise-Plan %{plan}, um diese Aktion durchzuführen."
error_enterprise_activation_user_limit: "Ihr Konto konnte nicht aktiviert werden (Nutzerlimit erreicht). Bitte kontaktieren Sie Ihren Administrator um Zugriff zu erhalten."
error_enterprise_token_invalid_domain: "Die Enterprise-Edition ist nicht aktiv. Die aktuelle Domain (%{actual}) entspricht nicht dem erwarteten Hostnamen (%{expected})."
error_enterprise_token_invalid_domain: "Die Enterprise edition ist nicht aktiv. Die aktuelle Domain (%{actual}) entspricht nicht dem erwarteten Hostnamen (%{expected})."
error_failed_to_delete_entry: "Fehler beim Löschen dieses Eintrags."
error_in_dependent: "Fehler beim Versuch, abhängiges Objekt zu ändern: %{dependent_class} #%{related_id} - %{related_subject}: %{error}"
error_in_new_dependent: "Fehler beim Versuch, abhängiges Objekt zu erstellen: %{dependent_class} - %{related_subject}: %{error}"
@@ -2466,7 +2460,7 @@ de:
upsell:
title: "Auf Enterprise edition upgraden"
links:
upgrade_enterprise_edition: "Auf Enterprise-Edition upgraden"
upgrade_enterprise_edition: "Auf Enterprise edition upgraden"
postgres_migration: "Migration Ihrer Installation zu PostgreSQL"
user_guides: "Benutzerhandbuch"
faq: "Häufig gestellte Fragen"
@@ -2504,7 +2498,7 @@ de:
dates:
working: "%{date} ist jetzt ein Arbeitstag"
non_working: "%{date} ist jetzt ein arbeitsfreier Tag"
progress_mode_changed_to_status_based: Fortschrittberechnung wurde auf Status-bezogen gesetzt
progress_mode_changed_to_status_based: Fortschrittberechnung wurde auf Status-basiert gesetzt
status_excluded_from_totals_set_to_false_message: jetzt in den Gesamtwerten der Hierarchie enthalten
status_excluded_from_totals_set_to_true_message: jetzt von den Hierarchie-Gesamtwerten ausgeschlossen
status_percent_complete_changed: "% abgeschlossen von %{old_value}% auf %{new_value} % geändert"
@@ -2796,7 +2790,7 @@ de:
label_enumerations: "Aufzählungen"
label_enterprise: "Enterprise"
label_enterprise_active_users: "%{current}/%{limit} gebuchte aktive Nutzer"
label_enterprise_edition: "Enterprise Edition"
label_enterprise_edition: "Enterprise edition"
label_enterprise_support: "Enterprise Support"
label_environment: "Umgebung"
label_estimates_and_progress: "Schätzungen und Fortschritt"
@@ -3798,7 +3792,7 @@ de:
update_timeout: "Speichere die Informationen bzgl. des genutzten Festplattenspeichers eines Projektarchivs für N Minuten.\nErhöhen Sie diesen Wert zur Verbesserung der Performance, da die Erfassung des genutzten Festplattenspeichers Ressourcen-intensiv ist."
oauth_application_details: "Der Client Geheimcode wird nach dem Schließen dieses Fensters nicht mehr zugänglich sein. Bitte kopieren Sie diese Werte in die Nextcloud OpenProject Integrationseinstellungen:"
oauth_application_details_link_text: "Zu den Einstellungen gehen"
setup_documentation_details: "Wenn Sie Hilfe bei der Konfiguration eines neuen Dateispeichers benötigen, konsultieren Sie bitte die Dokumentation: "
setup_documentation_details: "Wenn Sie Hilfe bei der Konfiguration eines neuen Datei-Speichers benötigen, konsultieren Sie bitte die Dokumentation: "
setup_documentation_details_link_text: "Dateispeicher einrichten"
show_warning_details: "Um diesen Dateispeicher nutzen zu können, müssen Sie das Modul und den spezifischen Speicher in den Projekteinstellungen jedes gewünschten Projekts aktivieren."
subversion:
@@ -4336,7 +4330,7 @@ de:
warning_user_limit_reached_admin: >
Das Hinzufügen zusätzlicher Benutzer überschreitet das aktuelle Benutzerlimit. Bitte <a href="%{upgrade_url}">aktualisieren Sie Ihr Abonnement</a> um sicherzustellen, dass externe Benutzer auf diese Instanz zugreifen können.
warning_user_limit_reached_instructions: >
Du hast dein Nutzerlimit erreicht (%{current}/%{max} active users). Bitte kontaktiere sales@openproject.com um deinen Enterprise Edition Plan upzugraden und weitere Nutzer hinzuzufügen.
Du hast dein Nutzerlimit erreicht (%{current}/%{max} active users). Bitte kontaktiere sales@openproject.com um deinen Enterprise edition Plan upzugraden und weitere Nutzer hinzuzufügen.
warning_protocol_mismatch_html: >
warning_bar:
+2 -8
View File
@@ -1238,6 +1238,7 @@ el:
error_unauthorized: "δεν είναι δυνατή η πρόσβαση."
error_readonly: "επιχειρήθηκε να εγγραφεί αλλά δεν ήταν εγγράψιμο."
error_conflict: "Οι πληροφορίες ενημερώθηκαν από τουλάχιστον έναν ακόμα χρήστη στο εν τω μεταξύ."
error_not_found: "not found."
email: "is not a valid email address."
empty: "δεν μπορεί να είναι κενό."
enterprise_plan_required: "requires at least the %{plan_name}."
@@ -1397,8 +1398,6 @@ el:
cannot_be_a_non_working_day: "can't be a non-working day."
query:
attributes:
project:
error_not_found: "δεν βρέθηκε"
public:
error_unauthorized: "- Ο χρήστης δεν έχει δικαίωμα να δημιουργήσει δημόσιες προβολές."
group_by:
@@ -1438,10 +1437,8 @@ el:
circular_dependency: "Η σχέση αυτή δημιουργεί έναν κύκλο σχέσεων."
attributes:
to_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
from_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
repository:
not_available: "Ο προμηθευτής SCM δεν είναι διαθέσιμος"
@@ -1581,10 +1578,6 @@ el:
description: "Ο 'Κωδικός Επιβεβαίωσης' θα πρέπει να ταιριάζει με την είσοδο του πεδίου 'Νέος Κωδικός'."
status:
invalid_on_create: "δεν είναι έγκυρη κατάσταση για νέους χρήστες."
ldap_auth_source:
error_not_found: "δεν βρέθηκε"
auth_source:
error_not_found: "δεν βρέθηκε"
member:
principal_blank: "Παρακαλούμε επιλέξτε τουλάχιστον ένα χρήστη ή ομάδα."
role_blank: "πρέπει να ανατεθεί."
@@ -1794,6 +1787,7 @@ el:
title: "Τίτλος"
type: "Τύπος"
typeahead: "Autocomplete"
uid: "Unique identifier"
updated_at: "Τροποποιήθηκε"
updated_on: "Τροποποιήθηκε"
uploader: "Μεταφορτώθηκε από"
+2 -8
View File
@@ -1242,6 +1242,7 @@ eo:
error_unauthorized: "ne povas esti atingita."
error_readonly: "was attempted to be written but is not writable."
error_conflict: "Information has been updated by at least one other user in the meantime."
error_not_found: "not found."
email: "is not a valid email address."
empty: "Ĝi ne povas esti malplena."
enterprise_plan_required: "requires at least the %{plan_name}."
@@ -1401,8 +1402,6 @@ eo:
cannot_be_a_non_working_day: "can't be a non-working day."
query:
attributes:
project:
error_not_found: "ne trovita"
public:
error_unauthorized: "- The user has no permission to create public views."
group_by:
@@ -1442,10 +1441,8 @@ eo:
circular_dependency: "The relationship creates a circle of relationships."
attributes:
to_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
from_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
repository:
not_available: "SCM vendor is not available"
@@ -1585,10 +1582,6 @@ eo:
description: "'Password confirmation' should match the input in the 'New password' field."
status:
invalid_on_create: "is not a valid status for new users."
ldap_auth_source:
error_not_found: "ne trovita"
auth_source:
error_not_found: "ne trovita"
member:
principal_blank: "Please choose at least one user or group."
role_blank: "ĝi bezonas esti atribuita."
@@ -1798,6 +1791,7 @@ eo:
title: "Titolo"
type: "Tipo"
typeahead: "Autocomplete"
uid: "Unique identifier"
updated_at: "Ĝisdatigita la"
updated_on: "Ĝisdatigita la"
uploader: "Alŝutilo"
+3 -9
View File
@@ -746,7 +746,7 @@ es:
automatic_with_children: "Fechas determinadas por paquetes de trabajo secundarios."
automatic_with_predecessor: "La fecha de inicio la fija un predecesor."
manual_mobile: "Programado manualmente."
manually_scheduled: "Programado manualmente. No afectadas por relaciones."
manually_scheduled: "Programado manualmente. Fechas no afectadas por relaciones."
blankslate:
title: "Sin predecesores"
description: "Para activar la programación automática, este paquete de trabajo debe tener al menos un predecesor. Entonces se programará automáticamente para que comience después del predecesor más cercano."
@@ -1239,6 +1239,7 @@ es:
error_unauthorized: "no se puede acceder."
error_readonly: "se intentó escribir pero no se puede escribir."
error_conflict: "La información ha sido actualizada por al menos otro usuario mientras tanto."
error_not_found: "no encontrado."
email: "no es una dirección de correo válida."
empty: "No puede estar vacío."
enterprise_plan_required: "requiere al menos el plan %{plan_name}."
@@ -1398,8 +1399,6 @@ es:
cannot_be_a_non_working_day: "no puede ser un día no laborable."
query:
attributes:
project:
error_not_found: "no encontrado"
public:
error_unauthorized: "- El usuario no tiene permiso para crear vistas públicas."
group_by:
@@ -1439,10 +1438,8 @@ es:
circular_dependency: "La relación crea un bucle de relaciones."
attributes:
to_id:
error_not_found: "no se encuentra o no es visible."
error_readonly: "no se puede cambiar para las relaciones existentes."
from_id:
error_not_found: "no se encuentra o no es visible."
error_readonly: "no se puede cambiar para las relaciones existentes."
repository:
not_available: "El proveedor SMC no está disponible"
@@ -1582,10 +1579,6 @@ es:
description: "'La confirmación de la contraseña' debe coincidir con la ingresada en el campo 'Nueva contraseña'."
status:
invalid_on_create: "no es un estado válido para nuevos usuarios."
ldap_auth_source:
error_not_found: "no encontrado"
auth_source:
error_not_found: "no encontrado"
member:
principal_blank: "Por favor seleccione al menos un usuario o grupo."
role_blank: "necesita ser asignado."
@@ -1795,6 +1788,7 @@ es:
title: "Título"
type: "Tipo"
typeahead: "Autocompletar"
uid: "Identificador único"
updated_at: "Actualizada el"
updated_on: "Actualizada el"
uploader: "Cargador"
+2 -8
View File
@@ -1242,6 +1242,7 @@ et:
error_unauthorized: "may not be accessed."
error_readonly: "was attempted to be written but is not writable."
error_conflict: "Teine kasutaja uuendas vahepeal neid andmeid."
error_not_found: "not found."
email: "is not a valid email address."
empty: "ei tohi olla tühi."
enterprise_plan_required: "requires at least the %{plan_name}."
@@ -1401,8 +1402,6 @@ et:
cannot_be_a_non_working_day: "can't be a non-working day."
query:
attributes:
project:
error_not_found: "not found"
public:
error_unauthorized: "- The user has no permission to create public views."
group_by:
@@ -1442,10 +1441,8 @@ et:
circular_dependency: "The relationship creates a circle of relationships."
attributes:
to_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
from_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
repository:
not_available: "SCM vendor is not available"
@@ -1585,10 +1582,6 @@ et:
description: "'Password confirmation' should match the input in the 'New password' field."
status:
invalid_on_create: "is not a valid status for new users."
ldap_auth_source:
error_not_found: "not found"
auth_source:
error_not_found: "not found"
member:
principal_blank: "Vali vähemalt üks kasutaja või grupp."
role_blank: "need to be assigned."
@@ -1798,6 +1791,7 @@ et:
title: "Pealkiri"
type: "Tüüp"
typeahead: "Autocomplete"
uid: "Unique identifier"
updated_at: "Uuendatud"
updated_on: "Uuendatud"
uploader: "Uploader"
+2 -8
View File
@@ -1242,6 +1242,7 @@ eu:
error_unauthorized: "may not be accessed."
error_readonly: "was attempted to be written but is not writable."
error_conflict: "Information has been updated by at least one other user in the meantime."
error_not_found: "not found."
email: "is not a valid email address."
empty: "can't be empty."
enterprise_plan_required: "requires at least the %{plan_name}."
@@ -1401,8 +1402,6 @@ eu:
cannot_be_a_non_working_day: "can't be a non-working day."
query:
attributes:
project:
error_not_found: "not found"
public:
error_unauthorized: "- The user has no permission to create public views."
group_by:
@@ -1442,10 +1441,8 @@ eu:
circular_dependency: "The relationship creates a circle of relationships."
attributes:
to_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
from_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
repository:
not_available: "SCM vendor is not available"
@@ -1585,10 +1582,6 @@ eu:
description: "'Password confirmation' should match the input in the 'New password' field."
status:
invalid_on_create: "is not a valid status for new users."
ldap_auth_source:
error_not_found: "not found"
auth_source:
error_not_found: "not found"
member:
principal_blank: "Please choose at least one user or group."
role_blank: "need to be assigned."
@@ -1798,6 +1791,7 @@ eu:
title: "Title"
type: "Type"
typeahead: "Autocomplete"
uid: "Unique identifier"
updated_at: "Updated on"
updated_on: "Updated on"
uploader: "Uploader"
+2 -8
View File
@@ -1242,6 +1242,7 @@ fa:
error_unauthorized: "may not be accessed."
error_readonly: "was attempted to be written but is not writable."
error_conflict: "Information has been updated by at least one other user in the meantime."
error_not_found: "not found."
email: "آدرس ایمیل معتبر نیست."
empty: "can't be empty."
enterprise_plan_required: "requires at least the %{plan_name}."
@@ -1401,8 +1402,6 @@ fa:
cannot_be_a_non_working_day: "can't be a non-working day."
query:
attributes:
project:
error_not_found: "یافت نشد"
public:
error_unauthorized: "- The user has no permission to create public views."
group_by:
@@ -1442,10 +1441,8 @@ fa:
circular_dependency: "رابطه دایره روابط ایجاد می کند."
attributes:
to_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
from_id:
error_not_found: "not found or not visible."
error_readonly: "cannot be changed for existing relations."
repository:
not_available: "SCM vendor is not available"
@@ -1585,10 +1582,6 @@ fa:
description: "'Password confirmation' should match the input in the 'New password' field."
status:
invalid_on_create: "is not a valid status for new users."
ldap_auth_source:
error_not_found: "یافت نشد"
auth_source:
error_not_found: "یافت نشد"
member:
principal_blank: "لطفا حداقل یک کاربر یا گروه را انتخاب کنید."
role_blank: "need to be assigned."
@@ -1798,6 +1791,7 @@ fa:
title: "عنوان"
type: "نوع"
typeahead: "Autocomplete"
uid: "Unique identifier"
updated_at: "به روز شده"
updated_on: "به روز شده"
uploader: "Uploader"

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