mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Merge branch 'dev' into merge-release/16.2-20250718035236
This commit is contained in:
@@ -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 }}" }}'
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+25
-20
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+5
-10
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(", ") %>
|
||||
|
||||
@@ -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 %>
|
||||
+2
-21
@@ -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 %>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,10 +28,8 @@
|
||||
|
||||
module Cron
|
||||
class ClearUploadedFilesJob < ApplicationJob
|
||||
include ::RakeJob
|
||||
|
||||
def perform
|
||||
super("attachments:clear")
|
||||
Attachment.clean_cached_files!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: "Μεταφορτώθηκε από"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user