diff --git a/.babelrc.bak b/.babelrc.bak deleted file mode 100644 index e976af7a6..000000000 --- a/.babelrc.bak +++ /dev/null @@ -1,10 +0,0 @@ -{ - "presets": [ - "@babel/preset-env", - "@babel/preset-react" - ], - "plugins": [ - "@babel/plugin-proposal-object-rest-spread", - "@babel/plugin-proposal-class-properties" - ] -} diff --git a/.env.development b/.env.development deleted file mode 100644 index e2cfa4b19..000000000 --- a/.env.development +++ /dev/null @@ -1,35 +0,0 @@ -SFTP_HOST=sftp -SFTP_PORT=22 -SFTP_UPLOAD_FOLDER=uploads -SFTP_USER=sftp_test -SFTP_PASSWORD=sftp_test - -MESSAGE_ENABLE=false -MESSAGE_AUTO_INTERNAL=6000 -MESSAGE_IDLE_TIME=12 - -# JWT key for novnc target encryption -NOVNC_SECRET='secret' - -# Allow unconfirmed email: leave blank for always, or set a number of days (integer); -# also set 0 to have email being confirmed before first sign in. -DEVISE_ALLOW_UNCONFIRMED='' - -# Disable sign up page: leave blank to allow sign up page. set to 'true' to disable it. -# only amdin can then create user accounts. -DEVISE_DISABLED_SIGN_UP='' - -# Any new account to be inactive by default => only admin can (de)activate -DEVISE_NEW_ACCOUNT_INACTIVE=false - -# Application's title -APPLICATION_TITLE='Chemotion dev' - -DATA_CITE_BASE_URI=https://api.test.datacite.org -DATA_CITE_PREFIX=prefix -DATA_CITE_API_USERNAME=username -DATA_CITE_API_PASSWORD=password -DATA_CITE_DEVICE_PREFIX=DEVICE- -DATA_CITE_RESEARCH_PLAN_PREFIX=RP- -DATA_CITE_DEVICE_PUBLISHER=chemotion.net -DATA_CITE_DEVICE_CREATOR=chemotion.net diff --git a/.env.production.example b/.env.production.example index 1fb4aed77..b60937b7f 100644 --- a/.env.production.example +++ b/.env.production.example @@ -29,11 +29,3 @@ DEVISE_NEW_ACCOUNT_INACTIVE=false # Application's title APPLICATION_TITLE='Chemotion' -DATA_CITE_BASE_URI=https://api.test.datacite.org -DATA_CITE_PREFIX=prefix -DATA_CITE_API_USERNAME=username -DATA_CITE_API_PASSWORD=password -DATA_CITE_DEVICE_PREFIX=DEVICE- -DATA_CITE_RESEARCH_PLAN_PREFIX=RP- -DATA_CITE_DEVICE_PUBLISHER=chemotion.net -DATA_CITE_DEVICE_CREATOR=chemotion.net diff --git a/.env.test b/.env.test index 136fc0fe9..fbc29a1ab 100644 --- a/.env.test +++ b/.env.test @@ -18,14 +18,3 @@ DEVISE_DISABLED_SIGN_UP='' # Any new account to be inactive by default => only admin can (de)activate DEVISE_NEW_ACCOUNT_INACTIVE=false - -DATA_CITE_BASE_URI=https://api.test.datacite.org -DATA_CITE_PREFIX=prefix -DATA_CITE_API_USERNAME=username -DATA_CITE_API_PASSWORD=password -DATA_CITE_DEVICE_PREFIX=DEVICE- -DATA_CITE_DEVICE_PUBLISHER=chemotion.net -DATA_CITE_DEVICE_CREATOR=chemotion.net - -# uncomment for headfull testing -# USE_HEAD=:D diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 36a5de4cf..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: bug -assignees: '' - ---- - -- [ ] I am an ELNer -- [ ] I am a developer -- [ ] I am a system/ELN admin - -# Expected Behavior - -Please describe the behavior you are expecting - -# Current Behavior - -What is the current behavior? - -# Failure Information - -Please help provide information about the failure if this is a bug. If it is not a bug, please remove the rest of this template. - -## Steps to Reproduce - -Please provide detailed steps for reproducing the issue. - -1. step 1 -2. step 2 -3. you get it... - -## Context - -Please provide any relevant information about your setup. This is important in case the issue is not reproducible except for under certain conditions. - - - OS: [e.g. iOS] - - [ ] Chrome Browser - - [ ] Firefox Browser - - ELN version, or branch, or commit - - -## Failure Logs / Screenshots - -Include any relevant log snippets or files here. diff --git a/.github/ISSUE_TEMPLATE/enhancement.md b/.github/ISSUE_TEMPLATE/enhancement.md deleted file mode 100644 index 904cc0246..000000000 --- a/.github/ISSUE_TEMPLATE/enhancement.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -name: enhancement Issue -about: About improving existing feature -title: '' -labels: '' -assignees: '' - ---- - -- [ ] I am an ELNer -- [ ] I am a developer -- [ ] I am a system/ELN admin - -# Description -Please provide a description of: -1. what enhancement you suggest -2. why you think it is needed (You might provide a use case here) - -# Image -Please provide an image of how you expect your suggestion to appear in the ELN. -Details of the location of the enhancement and any related buttons could be useful in this image. diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md deleted file mode 100644 index bf8db3370..000000000 --- a/.github/ISSUE_TEMPLATE/feature.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature -about: Getting new or revamping old feature -title: '' -labels: 'feature' -assignees: '' - ---- -- [ ] I am an ELNer -- [ ] I am a developer -- [ ] I am a system/ELN admin - -# Description -Please provide a description of: -1. what feature you suggest -2. why you think it is needed (You might provide a use case here) - -# Image -Please provide an image of how you expect your suggestion to appear in the ELN. -Details of the location of the feautre and any related buttons could be useful in this image. diff --git a/.github/ISSUE_TEMPLATE/greater-code.md b/.github/ISSUE_TEMPLATE/greater-code.md deleted file mode 100644 index 39fae20d4..000000000 --- a/.github/ISSUE_TEMPLATE/greater-code.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: Greater Code -about: About greater coding -title: '' -labels: 'greater code' -assignees: '' - ---- - - -- [ ] refactoring -- [ ] dependencies -- [ ] linting -- [ ] more testing - diff --git a/.github/ISSUE_TEMPLATE/installation-issue.md b/.github/ISSUE_TEMPLATE/installation-issue.md deleted file mode 100644 index 13cbad957..000000000 --- a/.github/ISSUE_TEMPLATE/installation-issue.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: Installation Issue -about: About getting things running -title: '' -labels: '' -assignees: '' - ---- - -I am setting up a -- [ ] production -- [ ] development environment - - -# Failure Information - -Please help provide information about the failure if this is a bug. If it is not a bug, please remove the rest of this template. - -## Context - -Please provide any relevant information about your setup. This is important in case the issue is not reproducible except for under certain conditions. - - -I am -- [ ] using Docker -- [ ] not using Docker - -- OS: -- ELN version, or branch, or commit - -## Steps to Reproduce - -1. step 1 -2. step 2 -3. you get it... - -## Failure Logs / Screenshots - -Include any relevant log snippets or files here. diff --git a/.github/PULL_REQUEST_TEMPLATE/generic.md b/.github/PULL_REQUEST_TEMPLATE/generic.md deleted file mode 100644 index bbbb9578a..000000000 --- a/.github/PULL_REQUEST_TEMPLATE/generic.md +++ /dev/null @@ -1,21 +0,0 @@ - - -- [] rather 1-story 1-commit than sub-atomic commits - -- [] commit title is meaningful => git history search - -- [] commit description is helpful => helps the reviewer to understand the changes - -- [] code is up-to-date with the latest developments of the target branch (rebased to it or whatever) => :fast_forward:-merge for linear history is favoured - -- [] added code is linted - -- [] tests are passing (at least locally): we still have some random test failure on CI. thinking of asking spec/examples.txt to be commited - -- [] in case the changes are visible to the end-user,  video or screenshots should be added to the PR => helps with user testing - -- [] testing coverage improvement is more than welcome. - -- [] CHANGELOG :  add a bullet point on top (optional: reference to github issue/PR ) - -- [] parallele PR for documentation  on docusaurus  if the feature/fix is tagged for a release diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index bbbb9578a..000000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,21 +0,0 @@ - - -- [] rather 1-story 1-commit than sub-atomic commits - -- [] commit title is meaningful => git history search - -- [] commit description is helpful => helps the reviewer to understand the changes - -- [] code is up-to-date with the latest developments of the target branch (rebased to it or whatever) => :fast_forward:-merge for linear history is favoured - -- [] added code is linted - -- [] tests are passing (at least locally): we still have some random test failure on CI. thinking of asking spec/examples.txt to be commited - -- [] in case the changes are visible to the end-user,  video or screenshots should be added to the PR => helps with user testing - -- [] testing coverage improvement is more than welcome. - -- [] CHANGELOG :  add a bullet point on top (optional: reference to github issue/PR ) - -- [] parallele PR for documentation  on docusaurus  if the feature/fix is tagged for a release diff --git a/.github/workflows/config.sh b/.github/workflows/config.sh deleted file mode 100644 index 398e8b93b..000000000 --- a/.github/workflows/config.sh +++ /dev/null @@ -1,6 +0,0 @@ -cd chemotion_ELN/config -sudo cp database.yml.gitlab database.yml -sudo cp -f storage.yml.example storage.yml -sudo touch datacollectors.yml -sudo cp -f profile_default.yml.example profile_default.yml -sudo touch klasses.json \ No newline at end of file diff --git a/.github/workflows/testacceptance.yml b/.github/workflows/testacceptance.yml deleted file mode 100644 index f58ab9194..000000000 --- a/.github/workflows/testacceptance.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Acceptance Tests - -on: - workflow_dispatch: - push: - -env: - NODE_OPTIONS: '--max_old_space_size=4096' - -jobs: - docker_container: - if: "!contains(github.event.commits[0].message, 'skip all') && !contains(github.event.commits[0].message, 'skip acceptance')" - runs-on: ubuntu-latest - strategy: - matrix: - pg_role_test: [chemotion_test] - pg_database_test: [chemotion_test] - pg_password: [123456] - container: - image: complat/complat-ubuntu-runner:development-5.44c4f24b - env: - HOME: /home/gitlab-runner - - services: - postgres: - image: postgres - ports: - - 5433:5432 - env: - POSTGRES_PASSWORD: postgres - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - steps: - - name: git clone + postgres - working-directory: /home/gitlab-runner - env: - POSTGRES_HOST: postgres - POSTGRES_PORT: 5433 - run: | - sudo git clone --branch $(echo $GITHUB_REF | cut -d'/' -f 3) --depth 1 https://github.com/${GITHUB_REPOSITORY} - - sudo bash ./chemotion_ELN/.github/workflows/config.sh - - echo "POSTGRES" - psql -d postgresql://postgres:postgres@postgres/postgres -c "CREATE ROLE ${{ matrix.pg_role_test }} LOGIN CREATEDB NOSUPERUSER PASSWORD '${{ matrix.pg_password }}'" - psql -d postgresql://postgres:postgres@postgres/postgres -c "CREATE DATABASE ${{ matrix.pg_database_test }} OWNER ${{ matrix.pg_role_test }};" - psql -d postgresql://${{ matrix.pg_role_test }}:${{ matrix.pg_password }}@postgres/${{ matrix.pg_database_test }} -c 'CREATE EXTENSION IF NOT EXISTS "pg_trgm"; CREATE EXTENSION IF NOT EXISTS "hstore"; CREATE EXTENSION IF NOT EXISTS "uuid-ossp";' - - sudo chmod -R +x chemotion_ELN/spec - sudo chown -R gitlab-runner:gitlab-runner chemotion_ELN - - - name: bundle + yarn install - working-directory: /home/gitlab-runner/chemotion_ELN - run: | - /bin/bash -l -c "source /home/gitlab-runner/.asdf/asdf.sh && bundle install" - /bin/bash -l -c "source /home/gitlab-runner/.asdf/asdf.sh && yarn install" - - - name: precompile - working-directory: /home/gitlab-runner/chemotion_ELN - run: | - /bin/bash -l -c "source /home/gitlab-runner/.asdf/asdf.sh && RAILS_ENV=test bundle exec rake db:migrate db:test:prepare > /dev/null" - - mv public/welcome-message-sample.md public/welcome-message.md - - /bin/bash -l -c "source /home/gitlab-runner/.asdf/asdf.sh && RAILS_ENV=test bundle exec rails webpacker:compile" - sleep 30 - - - name: testacceptance - working-directory: /home/gitlab-runner/chemotion_ELN - run: | - /bin/bash -l -c "source /home/gitlab-runner/.asdf/asdf.sh && RAILS_ENV=test bundle exec rspec spec/features" diff --git a/.github/workflows/testjs.yml b/.github/workflows/testjs.yml deleted file mode 100644 index 7ea2db60f..000000000 --- a/.github/workflows/testjs.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: JavaScript Unit Tests - -on: - workflow_dispatch: - push: - -jobs: - docker_container: - if: "!contains(github.event.commits[0].message, 'skip all') && !contains(github.event.commits[0].message, 'skip unit js')" - runs-on: ubuntu-latest - strategy: - matrix: - pg_role: [chemotion_test] - pg_database: [chemotion_test] - pg_password: [123456] - container: - image: complat/complat-ubuntu-runner:development-5.44c4f24b - env: - HOME: /home/gitlab-runner - - services: - postgres: - image: postgres - ports: - - 5432:5432 - env: - POSTGRES_PASSWORD: postgres - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - steps: - - name: git clone + postgres - working-directory: /home/gitlab-runner - env: - POSTGRES_HOST: postgres - POSTGRES_PORT: 5432 - - run: | - sudo git clone --branch $(echo $GITHUB_REF | cut -d'/' -f 3) --depth 1 https://github.com/ComPlat/chemotion_ELN.git - - sudo bash ./chemotion_ELN/.github/workflows/config.sh - - echo "POSTGRES" - psql -d postgresql://postgres:postgres@postgres/postgres -c "CREATE ROLE ${{ matrix.pg_role }} LOGIN CREATEDB NOSUPERUSER PASSWORD '${{ matrix.pg_password }}'" - psql -d postgresql://postgres:postgres@postgres/postgres -c "CREATE DATABASE ${{ matrix.pg_database }} OWNER ${{ matrix.pg_role }};" - psql -d postgresql://${{ matrix.pg_role }}:${{ matrix.pg_password }}@postgres/${{ matrix.pg_database }} -c 'CREATE EXTENSION IF NOT EXISTS "pg_trgm"; CREATE EXTENSION IF NOT EXISTS "hstore"; CREATE EXTENSION IF NOT EXISTS "uuid-ossp";' - - sudo chown -R gitlab-runner:gitlab-runner chemotion_ELN - - - name: bundle + yarn install - working-directory: /home/gitlab-runner/chemotion_ELN - run: | - /bin/bash -l -c "source /home/gitlab-runner/.asdf/asdf.sh && bundle install" - /bin/bash -l -c "source /home/gitlab-runner/.asdf/asdf.sh && yarn install" - - - name: npm test - working-directory: /home/gitlab-runner/chemotion_ELN - run: | - echo "RAKE DB" - /bin/bash -l -c "RAILS_ENV=test bundle exec rake db:test:prepare > /dev/null" - echo "NPM TEST" - /bin/bash -l -c "npm test" - diff --git a/.github/workflows/testrb.yml b/.github/workflows/testrb.yml deleted file mode 100644 index 881942a2b..000000000 --- a/.github/workflows/testrb.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Ruby Unit Tests - -on: - workflow_dispatch: - push: - -jobs: - docker_container: - if: "!contains(github.event.commits[0].message, 'skip all') && !contains(github.event.commits[0].message, 'skip unit rb')" - runs-on: ubuntu-latest - strategy: - matrix: - pg_role: [chemotion_test] - pg_database: [chemotion_test] - container: - image: complat/complat-ubuntu-runner:development-5.44c4f24b - env: - HOME: /home/gitlab-runner - - services: - postgres: - image: postgres - ports: - - 5432:5432 - env: - POSTGRES_PASSWORD: postgres - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - steps: - - name: git clone + postgres - working-directory: /home/gitlab-runner - env: - POSTGRES_HOST: postgres - POSTGRES_PORT: 5432 - run: | - sudo git clone --branch $(echo $GITHUB_REF | cut -d'/' -f 3) --depth 1 https://github.com/ComPlat/chemotion_ELN.git - - sudo bash ./chemotion_ELN/.github/workflows/config.sh - - echo "POSTGRES" - psql -d postgresql://postgres:postgres@postgres/postgres -c "CREATE ROLE ${{ matrix.pg_role }} LOGIN CREATEDB NOSUPERUSER PASSWORD '123456'" - psql -d postgresql://postgres:postgres@postgres/postgres -c "CREATE DATABASE ${{ matrix.pg_database }} OWNER ${{ matrix.pg_role }};" - psql -d postgresql://${{ matrix.pg_role }}:123456@postgres/${{ matrix.pg_database }} -c 'CREATE EXTENSION IF NOT EXISTS "pg_trgm"; CREATE EXTENSION IF NOT EXISTS "hstore"; CREATE EXTENSION IF NOT EXISTS "uuid-ossp";' - - sudo chmod -R +x chemotion_ELN/spec - sudo chown -R gitlab-runner:gitlab-runner chemotion_ELN - - - name: bundle + yarn install - working-directory: /home/gitlab-runner/chemotion_ELN - run: | - /bin/bash -l -c "source /home/gitlab-runner/.asdf/asdf.sh && bundle install" - /bin/bash -l -c "source /home/gitlab-runner/.asdf/asdf.sh && yarn install" - - - name: db - working-directory: /home/gitlab-runner/chemotion_ELN - run: | - /bin/bash -l -c "RAILS_ENV=test bundle exec bin/rails db:migrate db:test:prepare db:seed > /dev/null" - - - name: rb test - uses: nick-invision/retry@v2 - with: - timeout_minutes: 10 - max_attempts: 5 - retry_on: error - command: | - cd /home/gitlab-runner/chemotion_ELN - /bin/bash -l -c "RAILS_ENV=test bundle exec rspec --exclude-pattern spec/{features}/**/*_spec.rb" - diff --git a/.gitignore b/.gitignore index 09feae15f..4b15f0a27 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ .coveralls.yml .env +.env.development +.env.test +.env.production /config/matrices.json /config/klasses.json @@ -35,9 +38,11 @@ /config/profile_default.yml /config/compute_props.yml /config/converter.yml +/config/ketcher_service.yml !/config/data_collector_keys/.keep /config/database.yml +/config/repository_database.yml /config/storage.yml /config/spectra.yml /config/editors.yml @@ -47,6 +52,12 @@ /node_modules +/public/newsroom/* +!/public/newsroom/.keep +/public/howto/* +!/public/howto/.keep + +!/public/images/molecules/.keep /public/images/molecules/* !/public/images/molecules/.keep @@ -77,6 +88,7 @@ /public/images/* !/public/images/wild_card/ !/public/images/ghs/ +!/public/images/creative_common/ /public/editors/* !/public/editors/.keep @@ -90,12 +102,19 @@ !/public/ontologies/rxno.default.json !/public/ontologies/rxno.default.edited.json +/public/directives/* +!/public/images/directives/.keep +!/public/images/directives/directives.html + /uploads/* !/public/attachments/.keep /public/json/* !/public/json/schema.json +/public/jsmol/* +!/public/jsmol/.keep + /public/zip/* !/public/zip/.keep diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 53baf52d7..000000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,122 +0,0 @@ -image: "complat/complat-ubuntu-runner:development-5.44c4f24b" - -services: - - postgres:12-alpine - -variables: - POSTGRES_DB: chemotion_test - POSTGRES_USER: chemotion_test - POSTGRES_PASSWORD: "123456" - -.cachingbuild: - cache: - key: ${CI_COMMIT_REF_SLUG} - paths: - - public/ -# - .bundle/ -# - node_modules/ - - .env - -.caching: - extends: .cachingbuild - cache: - policy: pull - -.before_scripting: - before_script: -# - mv ~/shared/bundle/ ~/shared/bundle.bak/ -# - mv vendor/bundle/ ~/shared/ - - cp config/database.yml.gitlab config/database.yml - - cp -f config/storage.yml.example config/storage.yml - - cp -f config/profile_default.yml.example config/profile_default.yml - - touch config/datacollectors.yml - - touch config/klasses.json - -stages: - - build - - unit - - acceptance - -building: - extends: .cachingbuild - stage: build - before_script: - - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )' - - eval $(ssh-agent -s) - # - mkdir -p ~/.ssh - # - chmod 700 ~/.ssh - # - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - - # - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa - # - chmod 600 ~/.ssh/id_rsa - # - ssh-add ~/.ssh/id_rsa - # - ssh-keyscan git.scc.kit.edu >> ~/.ssh/known_hosts - # - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts - - | - if [ ${#SSH_PRIVATE_KEY} -ge 1000 ]; then - mkdir -p ~/.ssh - chmod 700 ~/.ssh - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh-add ~/.ssh/id_rsa - #echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts - ssh-keyscan git.scc.kit.edu >> ~/.ssh/known_hosts - chmod 600 ~/.ssh/known_hosts - echo "$SSH_CONFIG" > ~/.ssh/config - chmod 600 ~/.ssh/config - fi - - if test ! -d "node_modules"; then ln -s /home/gitlab-runner/shared/node_modules node_modules; fi - - cp config/database.yml.gitlab config/database.yml - - cp -f config/storage.yml.example config/storage.yml - - cp -f config/profile_default.yml.example config/profile_default.yml - - echo "$DOTENV" > .env - - touch config/datacollectors.yml - - touch config/klasses.json - script: - - /bin/bash -l -c "bundle install" - - /bin/bash -l -c "bundle list" - - /bin/bash -l -c "yarn install" - - /bin/bash -l -c "RAILS_ENV=test bundle exec bin/rails db:migrate db:test:prepare" - -testrb: - extends: - - .caching - - .before_scripting - stage: unit - script: - - /bin/bash -l -c "bundle install" - - /bin/bash -l -c "bundle list" - - /bin/bash -l -c "yarn install" - - /bin/bash -l -c "RAILS_ENV=test bundle exec bin/rails db:migrate db:test:prepare" - - /bin/bash -l -c "RAILS_ENV=test bundle exec bin/rails db:seed" - - /bin/bash -l -c "RAILS_ENV=test bundle exec rspec --exclude-pattern spec/{features}/**/*_spec.rb" - except: - variables: - - $CI_COMMIT_MESSAGE =~ /skip[ _-]unitrb/i - - $CI_COMMIT_MESSAGE =~ /skip[ _-]allunits?/i - -testjs: - extends: - - .caching - - .before_scripting - stage: unit - script: - - /bin/bash -l -c "bundle install" - - /bin/bash -l -c "yarn install" - - /bin/bash -l -c "RAILS_ENV=test bundle exec rake db:test:prepare && npm test" - except: - variables: - - $CI_COMMIT_MESSAGE =~ /skip[ _-]unitjs/i - - $CI_COMMIT_MESSAGE =~ /skip[ _-]allunits?/i - -testacceptance: - extends: - - .caching - - .before_scripting - stage: acceptance - script: - - /bin/bash -l -c "bundle install" - - /bin/bash -l -c "yarn install" - - /bin/bash -l -c "RAILS_ENV=test bundle exec bin/rails db:migrate db:test:prepare && bundle exec rake assets:precompile && bundle exec rspec spec/features" - except: - variables: - - $CI_COMMIT_MESSAGE =~ /skip[ _-]acceptance/i diff --git a/.gitlab/merge_request_templates/generic.md b/.gitlab/merge_request_templates/generic.md deleted file mode 100644 index 056bc73b5..000000000 --- a/.gitlab/merge_request_templates/generic.md +++ /dev/null @@ -1,12 +0,0 @@ - - -- [ ] rather 1-story 1-commit than sub-atomic commits -- [ ] commit title is meaningful => git history search -- [ ] commit description is helpful => helps the reviewer to understand the changes -- [ ] code is up-to-date with the latest developments of the target branch (rebased to it or whatever) => :fast_forward:-merge for linear history is favoured -- [ ] added code is linted -- [ ] tests are passing (at least locally): we still have some random test failure on CI. thinking of asking spec/examples.txt to be commited -- [ ] in case the changes are visible to the end-user,  video or screenshots should be added to the PR => helps with user testing -- [ ] testing coverage improvement is more than welcome. -- [ ] CHANGELOG :  add a bullet point on top (optional: reference to github issue/PR ) -- [ ] (parallele PR for documentation  on docusaurus  if the feature/fix is tagged for a release) diff --git a/.nvmrc b/.nvmrc index 158c00641..cab13a796 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v14.16.0 +v14.17.0 diff --git a/.rubocop.yml b/.rubocop.yml index b88640d27..7f7fe08e1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,7 +3,7 @@ inherit_gem: gitlab-styles: - rubocop-metrics.yml - - rubocop-rspec.yml + #- rubocop-rspec.yml require: - rubocop-rspec diff --git a/.simplecov b/.simplecov deleted file mode 100644 index aca747c8e..000000000 --- a/.simplecov +++ /dev/null @@ -1,7 +0,0 @@ -require 'simplecov' -require 'coveralls' - -SimpleCov.formatter = Coveralls::SimpleCov::Formatter -SimpleCov.start do - add_filter 'spec' -end diff --git a/CHANGELOG.md b/CHANGELOG.md index 074922c93..8cb220503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,356 +1,48 @@ -# Chemotion_ELN Changelog +# Chemotion_Repository Changelog -## [vMAJOR.MINOR.PATCH] -> yyyy-mm-dd +## [1.2.0] +> 2023-06-29 -* Features and Improvements: - * fast input: sample creation - - users can create samples by entering the CAS Registry Number or SMILES - * SciFinder-n Search: integrate SciFinder-n into ELN and user can search in SciFinder for substances/reactions/references/suppliers by structure -# [v1.1.3] -> 2022-04-05 +* Enhancements: -* Fixes - * npx-audit and Gem patches - * migration - * reaction prediction UI - - -## [v1.1.2] -> 2022-03-25 - -* Fixes - * upd chemspectra client: XRD d-value - * add product to reaction - - -## [v1.1.1] -> 2022-03-08 - -* Fixes - * uniq sample short-label while creating reaction - * customized toolbar refresh issue in quill editor - * rendering of the group list when deleting multiple groups - * permission to add elements to sync-collections - - -## [v1.1.0] -> 2022-01-18 - -* NB: - * Admin: if applicable, chemspectra backend should be updated to version 0.10.13 - * Developers: reactjs updated to 17 - -* Features and Improvements: - * export sample: literature option for excel list https://github.com/ComPlat/chemotion_ELN/issues/554 - * report: Add literature section in standard sample report https://github.com/ComPlat/chemotion_ELN/issues/554 - * report: Add more information to standard reaction report https://github.com/ComPlat/chemotion_ELN/issues/523 - * attachments-inbox: filename matching with sample’s “name” or “external name”. https://github.com/ComPlat/chemotion_ELN/issues/537 - * attachments-inbox: case-insensitive matching https://github.com/ComPlat/chemotion_ELN/issues/537 - * attachments-inbox: added labels “product” or “start material” if applicable https://github.com/ComPlat/chemotion_ELN/issues/537 - * attachments-inbox: when the assignment is completed, send a notification to user’s message box https://github.com/ComPlat/chemotion_ELN/issues/537 - * attachments-inbox: filename matching to support files from ELA system. - * chemspectra: UV-vis layout extension (part of https://github.com/ComPlat/chemotion_ELN/issues/531 ) - * chemspectra: Add further functions to XRD layout https://github.com/ComPlat/chemotion_ELN/issues/532 - * wellplate designer: select info and colour to be displayed in well (https://github.com/ComPlat/chemotion_ELN/issues/556, https://github.com/ComPlat/chemotion_ELN/issues/558) - * wellplate designer: print wellplate as pdf (https://github.com/ComPlat/chemotion_ELN/issues/555) - * name_abbreviation regexp validation is configurable - * swagger documentation: visibility of endpoints doc customizable - * rename tab 'literature' to 'references'. Please update the current profile_default.yml with the new one. - * Admin user management: add multiple users from file. - -* Fixes - * import collection failed due to some molecules cannot be created successfully [cannot create molecule with given molfile](https://git.scc.kit.edu/ComPlat/chemotion_ELN/-/issues/1351) - * filtering product samples of reactions when filter is on on sample list - * https://github.com/ComPlat/chemotion_ELN/issues/584 - * Notification channels: correct wrong data format - - -## [v1.0.3] -> 2021-10-21: https://github.com/ComPlat/chemotion_ELN/releases/tag/v1.0.3 - -* Fixes - * reseach-plan .docx export: convert SVG to PNG sample/reaction images - * collection import with sample missing molecule_name: use default value - * structure editors: added public/editors to ease set up - * node post-install fx to resolve node_modules path when located outside of app (docker) - - - -## [v1.0.2] -> 2021-10-19: https://github.com/ComPlat/chemotion_ELN/releases/tag/v1.0.2 + * published on ISO8601. ComPlat/chemotion_REPO#46 + * Add physical properties which are melting point and boiling point information to the publication page and review page. + * Introduction of the data protection mechanism where the system automatically locks data after scientists submit their work; they can unseal the data for editing. + * publish sample: pre-chosen license. ComPlat/chemotion_REPO#27 + * Introduction of the feature that users can see the information from the original one after transferring the data from ELN and click on it to redirect to the ELN instance. + * Upgrade node version. -* Fixes - * DB migration: fix typo that could prevent updating from 0.9.1 - * structure editor: chemdrawjs-20 support - * gate transfer: attachment checksum bckwrd compatibility - - -## [v1.0.1] -> 2021-10-11: https://github.com/ComPlat/chemotion_ELN/releases/tag/v1.0.1 - -* Improvements - * LCSS display info only from ECHA source - * QuillEditor: added special characters menu for ResearchPlan - -* Fixes - * postinstall rewrite of some imports in citation.js to fix wbpk assets compilation - * Reaction SVG refresh after editing or adding samples - * Fix Cron Jobs for LCSS and Pubchem Info - * dev: fix reaction seeds +## [1.1.0] +> 2023-06-12 +* Enhancements: -## [v1.0.0] -> 2021-09-22: https://github.com/ComPlat/chemotion_ELN/releases/tag/v1.0.0 + * Integration of Molecule Archive to enhance data visibility and management capabilities. + * Support for assigning Collection DOIs, enabling persistent identification of collections. + * Shibboleth support for streamlined authentication and authorization processes. + * JSON-LD format support for enriched metadata representation. + * Open API for convenient downloading of metadata in JSON-LD format. + * Addition of group leader review functionality, facilitating improved collaboration and oversight. + * Support for publishing MOF reactions. + * Introduction of the Converter service, enabling format conversion for data. + * Introduction of the Ketcher backend service. + * Enriched metadata support for various types of published samples. + * Redesigned sample representation on the landing page for improved usability. + * Support for ORCID authentication, providing seamless user identification. + * Upgraded Chemspectra function. + * Styling improvements to enhance the user experience. -* Improvements - * Resize private note field and remove save button [Private note rework #534](https://github.com/ComPlat/chemotion_ELN/issues/534) - * Add hover over information to generic elements' symbols [add hover over information to generic elements' symbols #524](https://github.com/ComPlat/chemotion_ELN/issues/524) - * Structure editor configuration [documentation](https://www.chemotion.net/chemotionsaurus/docs/eln/settings#structure-editor) +* Bug Fixes: -* Fixes - * Adapt install_development.sh to Rails 5 environment [adapt install_development.sh to Rails 5 environment #530](https://github.com/ComPlat/chemotion_ELN/pull/530) - * Tab headers in navigation items are italic [Tab headers in navigation items are italic #500](https://github.com/ComPlat/chemotion_ELN/issues/500) - * Reaction svg shrinking or dedoubling - * Reaction svg size become smaller and the svg is overlapping - * Prevent hidden cell from being added to visible layout in tabslayout - * QC curation tab is not working - - -## [v1.0.0-beta] -> 2021-08-26 - -* Updates - * upd to rails from 4.2 to 5.2 - * now using yarn instead of npm, and webpack instead of browserify - -* New features and improvements - * Private notes for samples - * Generic elements/segments/datasets - - Element details tab layout: segment tab to show if data present [Element details tab layout: segment tab to show if data present #506](https://github.com/ComPlat/chemotion_ELN/issues/506) - - In user view, enable the sorting of lines of a table [generic elements, segments and analyses #480](https://github.com/ComPlat/chemotion_ELN/issues/480) - - Add samples to generic element in table #461 [Add samples to generic element in table #461](https://github.com/ComPlat/chemotion_ELN/issues/461) - - Generic element/segment units (Joule) [generic element/segment units (Joule) #457](https://github.com/ComPlat/chemotion_ELN/issues/457) - - Administrator can export/import the generic template [generic template upload and download #444](https://github.com/ComPlat/chemotion_ELN/issues/444) - - Revision control, track changes of the template and user inputs [generic revision feature #443](https://github.com/ComPlat/chemotion_ELN/issues/443) - - Drag and drop sample/molecule to the table [ELN Adminstration/generic elements: Create a table for drag and drop Sample/Molecule #437](https://github.com/ComPlat/chemotion_ELN/issues/437) - - new units for generic sections [new units for generic sections #436](https://github.com/ComPlat/chemotion_ELN/issues/436) - - new units for generic sections [new units for generic sections #434](https://github.com/ComPlat/chemotion_ELN/issues/434) - - Add new field type: Upload in generic element/segment #400 [Upload option in generic element/segment #400](https://github.com/ComPlat/chemotion_ELN/issues/400) - * Send welcome email for new users [Customized welcome email to new user #483](https://github.com/ComPlat/chemotion_ELN/issues/483) - * Instance customizable home page as MD file [Display customized welcome message at home page #470](https://github.com/ComPlat/chemotion_ELN/issues/470) - * Decoupled sample - - Remove name in scheme for decoupled sample [remove name in scheme for decoupled sample #465](https://github.com/ComPlat/chemotion_ELN/issues/465) - - Add "undefined structure" as default value for decoupled samples [Add "undefined structure" as default value for decoupled samples #463](https://github.com/ComPlat/chemotion_ELN/issues/463) - - Provide table function in generic element/segment [table function #414](https://github.com/ComPlat/chemotion_ELN/issues/414) - * Revamp of analytics inbox - * Report template management - * Device metadata + * Addressed an issue with the exporting function when data includes hyperlinks. + * Resolved a QC issue related to the usage of 'mass spectrometry' and 'IR'. + * Corrected the nmrium aasm_state to ensure accurate representation. + * Fixed an issue where the embargo job would get stuck if the mail server did not respond. * Fixes - * rename chemotion.net to chemotion-repository.net in the Collection Bar [rename chemotion.net to chemotion-repository.net in the Collection Bar #515](https://github.com/ComPlat/chemotion_ELN/issues/515) - * input field on ipad [Not all fields work normally with ipad #458](https://github.com/ComPlat/chemotion_ELN/issues/458) - * The permission level "delete" in sharing collections doesn't work and the receiver can't delete [The permission level "delete" in sharing collections doesn't work and the receiver can't delete #425](https://github.com/ComPlat/chemotion_ELN/issues/425) - * truncated reaction svg - * upload of large files - * reload of reports - - -## [v0.9.1] -> 2021-06-04: https://github.com/ComPlat/chemotion_ELN/releases/tag/v0.9.1 - -* Fixes - * sync/share deletion permission - * Admin: segment deletion - - -## [v0.9.0] -> 2021-05-26: https://github.com/ComPlat/chemotion_ELN/releases/tag/v0.9.0 - -* Features - * Decoupled sample: molfile-less sample creation - * Generic-element/segment (Beta): customizable data structure - -* Fixes - * serialization of sdf-imported molecules with improper file encoding - - -## [v0.8.0] -> 2021-04-28: https://github.com/ComPlat/chemotion_ELN/releases/tag/v0.8.0 - -* Features - * option to decouple sample from molfile defined molecule - enable user custom MW input (Admin has to enable the feature) - * research-plan table: switch to using ag-grid - enable moving row/columns with d-n-d - * nmr_sim ELN plugin installed by default - -* Fixes - * Add configurable default profile for Element-Detail tab sortings - * slow opening of reaction panel: rm debounce - * green-chemistry: fix checkbox rerendering in table cell - * Analytics atom count scenari - * default favicon if none present - - -## [v0.7.1] -> 2021-03-26: https://github.com/ComPlat/chemotion_ELN/releases/tag/v0.7.1 - -* Features - * sorting of tabs in Element-detail Panel (Sample, Reaction,...) - -* Fixes - * mimemagic gem updated due to previous version forced retirement - * research-plan table: cell focus input - * slow opening of reaction panel: rm debounce - - -## [v0.7.0] -> 2021-03-15: https://github.com/ComPlat/chemotion_ELN/releases/tag/v0.7.0 - - -## [v0.6.0] -> 2019-12-16: https://github.com/ComPlat/chemotion_ELN/releases/tag/v0.6.0 - -* Features - * minor UX improvements - * admin UI for data-collector and noVNC connection - * improve report UI performance - - -## [v0.5.0] -> 2019-10-08 - -* Features - * Analysis type according to Chemical Ontology (owl) - * export data from whole collections as zip (and import them) - * server notification system to users. E.g: user get notified when: - - the report generation is done - - it needs to refresh the browsers page to reload the cached application (update) - * Admin UI: - - NB: migrations will seed a default admin account => you need to change its password - email: `eln-admin@kit.edu` , pw: `PleaseChangeYourPassword` - - basic user management functions + direct notification to user - - DataCollector device configuration - - global notification to users - * Green Chemistry calculator for reaction - * RInChI integration - * new reporting functions - * sample/reaction large image preview from their element lists on hover - * clone samples/reactions to one's Chemotion Repository account (chemotion.net) - -* Upgrade Notes: - * must do: change the default password of the default admin account (vide supra) - * should do: reports are now stored as attachments run rake task - `data:ver_20180812115719_add_colums_to_attachment` to ensure the retrievability of previously generated - reports - - -## [v0.4.0] -> yyyy-mm-dd - -* Features - * Element list: added time range filter / product filter - * enhanced data collector, new configuration (Breaking change) - * Export reactions as reaction smiles - * Extract RSMI from docx embedded cdx object - -* Fixes - * Report formating - * shared collection from deleted account - - -## [v0.3.dev] -> yyyy-mm-dd - -* Features - * upg Rails to 4.2.10 nvm to 6.10.2 - -* Fixes - * Docker: node_modules as volume - * collection_tag update after element re-assignmnet to former collection - - -## [v0.3.0] -> 2017-11-15: https://github.com/ComPlat/chemotion_ELN/releases/tag/v0.3.0 - -* Features - * Export reaction smiles - * reporting: revamp of UI - * reporting: analysis metadata formating - * user affiliations management - * revamp of sample/reaction detail UI - * select the sample molecule_name to display - * fetch CAS from pubchem - * uuid (qr code) for each element (sample, reaction, analyse) - * export samples as sdf - * adv search - * Docker install - -* Fixes - * search result element ordering - * dependent destroy of collections-elements - * sample density default set to 0 - * react production build - - -## [v0.2.1] -> 2016-11-02 - -* Features - * Temperature Chart with unit converter (°C, °F , K) for reaction - * Import molecules and create samples from a sd file - (R group and polymer support are not supported) - * Images in report doc are from eps files - * rearrange the reaction list for a report by Drag-and-drop -ing - * Text editing and formating tool bar for the reaction description - -* Fixes - * upgrade to Ruby 2.3.1 - * The Upload of analyses files does not fail if the thumbnail creation does - - -## [v0.2.0] -> 2016-10-17: https://github.com/ComPlat/chemotion_ELN/releases/tag/v0.2.0 - -* Features - * sharing data with a group of users - * substructure search with fingerprint algorithm - * multiple solvents for reactions - * drag-and-drop of sample to reaction solvent - * user-defined chemical structure templates - * common chemical structure templates - * synchronized collections - * added full-screeen button to element view - * database and attachments backups - * better import feature - * advanced export options - * generate reports in .doc format - * different naming for reaction product - * add concentration to sample in solution - * updates in reporting system - * molecular weight calculation in real time Ketcherails v0.1.1 - * molecular weight calculation for selected structure part in - real time Ketcherails v0.1.1 - * warning for user when editing parent/child sample: parent/child samples - structures are not automatically updated - * reactants are not appearing in the samples list anymore - * added more details to sample analyses header - * style and user interface improvements - * zooming of reactions SVGs - -* Fixes - * user created samples counter is not decremented on deletion - * add material for reaction - * do not show non-saved collection in collection tree - * edit sample from reaction - * better scaling of sample and reaction SVG images - * multiple bug fixes - - -## [v0.1.0] -> 2016-05-31: https://github.com/ComPlat/chemotion_ELN/releases/tag/v0.1.0 - -* Features - * ELN for (organic) chemistry + * npx-audit and Gem patches + * migration + * reaction prediction UI diff --git a/CITATION.cff b/CITATION.cff deleted file mode 100644 index 0e948137c..000000000 --- a/CITATION.cff +++ /dev/null @@ -1,31 +0,0 @@ -cff-version: 1.2.0 -message: "Please cite this software as specified in the 'preferred-citation' section below." -title: "Chemotion Electronic Lab Notebook (ELN)" -authors: - - name: "Chemotion ELN contributors" -url: "https://github.com/ComPlat/chemotion_ELN" -preferred-citation: - type: article - authors: - - family-names: "Tremouilhac" - given-names: "Pierre" - - family-names: "Nguyen" - given-names: "An" - - family-names: "Huang" - given-names: "Yu-Chieh" - - family-names: "Kotov" - given-names: "Serhii" - - family-names: "Lütjohann" - given-names: "Dominic Sebastian" - - family-names: "Hübsch" - given-names: "Florian" - - family-names: "Jung" - given-names: "Nicole" - - family-names: "Bräse" - given-names: "Stefan" - doi: "10.1186/s13321-017-0240-0" - journal: "Journal of Cheminformatics" - title: "Chemotion ELN: an Open Source electronic lab notebook for chemists in academia" - issue: 1 - volume: 9 - year: 2017 diff --git a/Capfile b/Capfile index 31a756efe..93b36de2c 100644 --- a/Capfile +++ b/Capfile @@ -3,10 +3,11 @@ require 'capistrano/setup' # Include default deployment tasks require 'capistrano/deploy' +require 'capistrano/asdf' require "capistrano/scm/git" install_plugin Capistrano::SCM::Git -require 'capistrano/rvm' # Ruby version manager -require 'capistrano/nvm' # Node version manager +# require 'capistrano/rvm' # Ruby version manager +# require 'capistrano/nvm' # Node version manager require 'capistrano/bundler' require 'capistrano/rails/migrations' require 'capistrano/rails/assets' diff --git a/Dockerfile.focal.gitlab-ci b/Dockerfile.focal.gitlab-ci deleted file mode 100644 index 25af5bbc4..000000000 --- a/Dockerfile.focal.gitlab-ci +++ /dev/null @@ -1,81 +0,0 @@ -FROM ubuntu:focal - -ARG DEBIAN_FRONTEND=noninteractive -ARG VRUBY=2.6.6 -ARG VNODE=14.16.0 -ARG VNODENEXT=14.17.5 - -RUN set -xe && apt-get update -yqqq --fix-missing && apt-get upgrade -y -RUN apt update && apt-get install -yqq --fix-missing bash ca-certificates wget apt-transport-https git gpg\ - imagemagick libmagic-dev libmagickcore-dev libmagickwand-dev curl gnupg2 \ - build-essential nodejs sudo postgresql-client libappindicator1 swig \ - gconf-service libasound2 libgconf-2-4 cmake \ - libnspr4 libnss3 libpango1.0-0 libxss1 xdg-utils tzdata libpq-dev \ - gtk2-engines-pixbuf \ - libssl-dev libreadline-dev\ - unzip openssh-client \ - python-dev libsqlite3-dev libboost-all-dev p7zip-full \ - xfonts-cyrillic xfonts-100dpi xfonts-75dpi xfonts-base xfonts-scalable \ - fonts-crosextra-caladea fonts-crosextra-carlito \ - fonts-dejavu fonts-dejavu-core fonts-dejavu-extra fonts-liberation2 fonts-liberation \ - fonts-linuxlibertine fonts-noto-core fonts-noto-extra fonts-noto-ui-core \ - fonts-opensymbol fonts-sil-gentium fonts-sil-gentium-basic -RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ - && echo "deb http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list \ - && apt-get update -yqqq && apt-get -y install google-chrome-stable \ - && CHROMEDRIVER_VERSION=`curl -sS chromedriver.storage.googleapis.com/LATEST_RELEASE` \ - && mkdir -p /opt/chromedriver-$CHROMEDRIVER_VERSION \ - && curl -sS -o /tmp/chromedriver_linux64.zip http://chromedriver.storage.googleapis.com/$CHROMEDRIVER_VERSION/chromedriver_linux64.zip \ - && unzip -qq /tmp/chromedriver_linux64.zip -d /opt/chromedriver-$CHROMEDRIVER_VERSION \ - && rm /tmp/chromedriver_linux64.zip \ - && chmod +x /opt/chromedriver-$CHROMEDRIVER_VERSION/chromedriver \ - && ln -fs /opt/chromedriver-$CHROMEDRIVER_VERSION/chromedriver /usr/local/bin/chromedriver -RUN apt-get clean \ - && rm -rf /var/lib/apt/lists/* -RUN useradd -ms /bin/bash gitlab-runner \ - && echo "gitlab-runner ALL=NOPASSWD: ALL" >> /etc/sudoers - -USER gitlab-runner -WORKDIR /home/gitlab-runner - -SHELL ["/bin/bash", "-c"] - -RUN git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.8.1 - -ENV ASDF_DIR=/home/gitlab-runner/.asdf -ENV PATH=/home/gitlab-runner/.asdf/shims:/home/gitlab-runner/.asdf/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin - -RUN asdf plugin add nodejs https://github.com/asdf-vm/asdf-nodejs.git -RUN asdf install nodejs $VNODE -RUN asdf install nodejs $VNODENEXT -RUN asdf global nodejs $VNODE - -RUN asdf plugin add ruby https://github.com/asdf-vm/asdf-ruby.git -RUN asdf install ruby $VRUBY -RUN asdf global ruby $VRUBY - -RUN mkdir -p shared/node_modules -RUN ln -s shared/node_modules node_modules - - -COPY Gemfile /home/gitlab-runner/ -COPY Gemfile.lock /home/gitlab-runner/ -COPY package.json /home/gitlab-runner/ -COPY yarn.lock /home/gitlab-runner/ -COPY package_postinstall.sh /home/gitlab-runner/ - -RUN sudo chmod 666 Gemfile.lock -RUN sudo chmod 666 yarn.lock - -RUN /bin/bash -l -c "npm install -g yarn" -RUN /bin/bash -l -c "yarn install" - - -RUN /bin/bash -l -c "gem install bundler -v 1.17.3 && bundle install " -#RUN /bin/bash -l -c "chromedriver-update" -RUN sudo apt -yy remove lib*-dev -RUN sudo apt-get -y --autoremove --fix-missing install \ - libboost-serialization1.71.0 \ - libboost-iostreams1.71.0 \ - libboost-system1.71.0 - diff --git a/Gemfile b/Gemfile index 607f505cc..febd2855f 100644 --- a/Gemfile +++ b/Gemfile @@ -14,15 +14,13 @@ gem 'bootsnap' gem 'bootstrap-sass', '~> 3.4.1' gem 'charlock_holmes' -# gem 'chem_scanner', git: 'git@git.scc.kit.edu:ComPlat/chem_scanner.git' -gem 'chem_scanner', git: 'https://github.com/complat/chem_scanner.git' - gem 'closure_tree' gem 'countries' gem 'daemons' gem 'delayed_cron_job' gem 'delayed_job_active_record' +gem 'activejob_dj_overrides' gem 'devise' gem 'dotenv-rails', require: 'dotenv/rails-now' @@ -68,6 +66,10 @@ gem 'omniauth-github', '~> 1.4.0' gem 'omniauth-orcid', git: 'https://github.com/datacite/omniauth-orcid' gem 'omniauth_openid_connect' gem 'omniauth-oauth2', '~> 1.7', '>= 1.7.2' +gem 'omniauth-shibboleth' + +#gem 'chem_scanner', git: 'git@git.scc.kit.edu:ComPlat/chem_scanner.git' +gem 'ffi-rzmq' gem 'pandoc-ruby' gem 'paranoia', '~> 2.0' @@ -110,6 +112,7 @@ gem 'webpacker', git: 'https://github.com/rails/webpacker', branch: 'master' gem 'whenever', require: false gem 'yaml_db' +gem 'moneta' group :development do gem 'better_errors' # allows to debug exception on backend from browser @@ -191,4 +194,3 @@ eln_plugin = File.join(File.dirname(__FILE__), "Gemfile.plugin") if File.exists?(eln_plugin) eval_gemfile eln_plugin end - diff --git a/Gemfile.lock b/Gemfile.lock index 007be5a3f..49d8e22da 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -33,17 +33,6 @@ GIT redcarpet (>= 3.4.0) rubyzip (>= 1.1) -GIT - remote: https://github.com/complat/chem_scanner.git - revision: f98ed3304f5a5a91123e6f8f8cb58acc7d73ce9e - specs: - chem_scanner (0.1.3) - chronic_duration (>= 0.10) - nokogiri (>= 1.8) - rdkit_chem - ruby-geometry (>= 0.0.6) - ruby-ole (>= 1.2) - GIT remote: https://github.com/complat/ketcher-rails.git revision: 287c848ad4149caf6466a1b7a648ada017d30304 @@ -168,11 +157,14 @@ GEM activejob-status (0.1.5) activejob (>= 4.2) activesupport (>= 4.2) - activemodel (5.2.7) - activesupport (= 5.2.7) - activerecord (5.2.7) - activemodel (= 5.2.7) - activesupport (= 5.2.7) + activejob_dj_overrides (0.2.0) + delayed_job + rails (>= 4.2) + activemodel (5.2.6) + activesupport (= 5.2.6) + activerecord (5.2.6) + activemodel (= 5.2.6) + activesupport (= 5.2.6) arel (>= 9.0) activestorage (5.2.7) actionpack (= 5.2.7) @@ -263,8 +255,6 @@ GEM childprocess (2.0.0) rake (< 13.0) chronic (0.10.2) - chronic_duration (0.10.6) - numerizer (~> 0.1.1) chunky_png (1.3.11) climate_control (0.2.0) closure_tree (7.0.0) @@ -286,7 +276,7 @@ GEM css_parser (1.7.0) addressable daemons (1.3.1) - database_cleaner (1.7.0) + database_cleaner (1.8.4) debug_inspector (0.0.3) declarative (0.0.20) delayed_cron_job (0.7.2) @@ -327,6 +317,10 @@ GEM faraday (>= 0.7.4, < 1.0) fast_stack (0.2.0) ffi (1.13.1) + ffi-rzmq (2.0.7) + ffi-rzmq-core (>= 1.0.7) + ffi-rzmq-core (1.0.7) + ffi flamegraph (0.9.5) font-awesome-rails (4.7.0.5) railties (>= 3.2, < 6.1) @@ -448,8 +442,9 @@ GEM nokogiri (~> 1) rake mini_mime (1.1.2) - mini_portile2 (2.6.1) - minitest (5.15.0) + mini_portile2 (2.4.0) + minitest (5.14.4) + moneta (1.4.2) msgpack (1.3.3) multi_json (1.13.1) multi_xml (0.6.0) @@ -470,7 +465,6 @@ GEM nokogiri (1.12.5) mini_portile2 (~> 2.6.1) racc (~> 1.4) - numerizer (0.1.1) oauth2 (1.4.7) faraday (>= 0.8, < 2.0) jwt (>= 1.0, < 3.0) @@ -486,6 +480,8 @@ GEM omniauth-oauth2 (1.7.2) oauth2 (~> 1.4) omniauth (>= 1.9, < 3) + omniauth-shibboleth (1.3.0) + omniauth (>= 1.0.0) omniauth_openid_connect (0.4.0) addressable (~> 2.5) omniauth (>= 1.9, < 3) @@ -512,7 +508,7 @@ GEM parallel (1.17.0) paranoia (2.4.2) activerecord (>= 4.0, < 6.1) - parser (2.6.4.1) + parser (2.7.1.0) ast (~> 2.4.0) pdf-core (0.7.0) pg (0.20.0) @@ -763,6 +759,7 @@ PLATFORMS DEPENDENCIES aasm activejob-status + activejob_dj_overrides ancestry annotate api-pagination @@ -787,7 +784,6 @@ DEPENDENCIES capistrano-yarn capybara (~> 3.29.0) charlock_holmes - chem_scanner! chronic closure_tree countries @@ -803,6 +799,7 @@ DEPENDENCIES faraday (~> 0.12.1) faraday_middleware (~> 0.12.1) fast_stack + ffi-rzmq flamegraph font-awesome-rails fun_sftp! @@ -830,6 +827,7 @@ DEPENDENCIES listen memory_profiler meta_request + moneta net-sftp net-ssh nokogiri @@ -837,6 +835,7 @@ DEPENDENCIES omniauth-github (~> 1.4.0) omniauth-oauth2 (~> 1.7, >= 1.7.2) omniauth-orcid! + omniauth-shibboleth omniauth_openid_connect openbabel (= 2.4.90.3)! pandoc-ruby diff --git a/INSTALL.md b/INSTALL.md index 71ffc02ef..1786a7cc5 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -2,18 +2,15 @@ ## On a Ubuntu server -Server requirement: the installation can fail if not enough memory is available. A minimum of 3GB memory is recommended. - -copy the installation script on a ubuntu server 18.04 or 20.04 (also works with debian buster) +copy the installation script on a ubuntu server 18.04 (could work with another deb) ``` -curl -o chemotion_ELN_install.sh -L https://github.com/ComPlat/chemotion_ELN/raw/development-5/scripts/install_production.sh +curl -o chemotion_ELN_install.sh -L https://git.scc.kit.edu/complat/chemotion_ELN_server/raw/development/scripts/install_production.sh ``` +check the variables at the beginning of the file, but also check the whole script to see what it is doing. -Check the variables at the beginning of the file, but also check the whole script to see what it is doing. - -**TLDR**: the script will ... +**TLDR**: it will ... * install OS package dependencies * install passenger @@ -26,74 +23,79 @@ Check the variables at the beginning of the file, but also check the whole scrip When ready, make the script executable and run it as a non-root user (but in the sudo group): -``` -chmod 700 chemotion_ELN_install.sh +`chmod 700 chemotion_ELN_install.sh -sudo ./chemotion_ELN_install.sh -``` +`sudo ./chemotion_ELN_install.sh` After reboot the application should be up and running at the ip of the machine (http://...) An admin account should have been created (email: eln-admin@kit.edu, pw: PleaseChangeYourPassword) To update the application code for such an installation, use the update script: - ``` -curl -o chemotion_ELN_update.sh -L https://github.com/ComPlat/chemotion_ELN/raw/development-5/scripts/update_production.sh +curl -o chemotion_ELN_update.sh -L https://git.scc.kit.edu/complat/chemotion_ELN_server/raw/development/scripts/update_production.sh ``` - If needed, edit the file (change the variables or comments out parts to disable), then ``` -chmod 700 chemotion_ELN_update.sh +chmod 700 chemotion_ELN_update.sh` sudo ./chemotion_ELN_update.sh ``` -## Using Windows Subsytem for Linux 2 - -The instalation script works with Ubuntu 20 under WSL2. -NB: - -- openssh-server should be reinstalled. -- services (postgres, nginx) needs to be started manually. -- UFW should not be used and disabled. +## Using Docker +This is a setup for a 'pseudo' production stage using passenger and aimed for user testing. +(For the development environment, change 'RAILS_ENV' to 'development' in docker-compose.yml) +**Make sure you have finished the BASIC SETUP FIRST** -## Using Docker +1. Build the image from Dockerfile `docker-compose build` or pull the image: `docker-compose pull` +2. Initialize database FIRST: + * `docker-compose run app bundle exec rake db:create` + * `docker-compose run app bundle exec rake db:migrate` + * `docker-compose run app bundle exec rake db:seed` (optional). A "seed" + user will be inserted into the db with the information as below: template.moderator@eln.edu - password: "@eln.edu" + * `docker-compose run app rake ketcherails:import:common_templates` (optional) +3. Precompile assets: `docker-compose run app bundle exec rake assets:precompile` +4. To start the server: `docker-compose up` or start server and detach: `docker-compose up -d` -see online [docs](https://www.chemotion.net/chemotionsaurus/docs/eln/docker_installation) +* Start interactive shell with docker: `docker-compose run app /bin/bash` +* NOTE: In this Docker image, we disabled the email verification progress +* To enable email confirmation, uncomment ":confirmable" at line 5 of `app/models/user.rb`, stop the `docker-compose` by `docker-compose stop` and start `docker-compose`. # Basic Development Setup -## Ubuntu native or under WSL-2 +* Copy `config/database.yml.example` to `config/database.yml` and enter your database connection information. +* Copy `config/storage.yml.example` to `config/storage.yml` and enter your database connection information. +* Copy `.ruby-gemset.example` to `.ruby-gemset`. +* Copy `.ruby-version.example` to `.ruby-version`. (Skip this step if you want to use Docker) +* Reload directory to create rvm gemset. -See the scripts/install_development.sh for guidance or run it. Application should be all set up and ready to run. +## Application Setup +* Execute `bundle install`. +* Execute `rake db:reset` (this creates and seeds the database). -When using WLS-2: -- postgres service needs to be started (```sudo service postgresql start ```) -- you may want to move the application code somewhere to /mnt/... -- bind the WSL ip address when starting the rails s (`rails s -b ip.ad.dr.ess`) +## Environment variables +Production -## Using Docker +* `cp .env.production{.example,} # optionally enter SFTP credentials` -see https://github.com/ptrxyz/chemotion/tree/main/client-chemotion-dev +## Configure Data Collection +* copy the config example file and edit the entries +* create device entries and configure their profiles -## Application Setup Notes +`cp db/datacollectors.yml.example db/datacollectors.yml` -* config/database.yml and config/storage.yml are needed to start the application. -* Copy `.ruby-version.example` to `.ruby-version`. (Skip this step if you want to use Docker) -* Environment variables: see the corresponding .env files +## Deployment notes -### Resetting the db: If you like to reset the database, you have to execute the following commands (under the assumption your production database is called `chemotion`) ``` @@ -118,7 +120,7 @@ RAILS_ENV=production bundle exec rake db:migrate db:seed ## JS Setup & Testing -* Install `nvm`: `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/0.35.0/install.sh | bash` (see https://github.com/nvm-sh/nvm#installation) +* Install `nvm`: `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/0.34.0/install.sh | bash` (see https://github.com/nvm-sh/nvm#installation) or for OSX: `brew install nvm && echo "source $(brew --prefix nvm)/nvm.sh" >> ~/.profile` * Execute `nvm install 10.15.3` * Execute `npm install -g npm@6.11.3` diff --git a/README.md b/README.md index a5ddbea51..724845d01 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,26 @@ -# Chemotion [![Badge DOI]][DOI] +# Chemotion Repository [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.3755759.svg)](https://doi.org/10.5281/zenodo.3755759) -*An* ***Electronic Lab Notebook*** *for chemists!* +Empowering chemists with a comprehensive solution for storing, managing, and analyzing samples, reactions, and research data. --- -**⸢ [Installation] ⸥ ⸢ [Documentation] ⸥ ⸢ [Changelog] ⸥** +## License ---- +**Copyright © `2015` - `2023` [Nicole Jung]** 
+of the **[Karlsruhe Institute of Technology]**. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. -## Unit Tests + 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 Affero General Public License for more details. -[![Badge JS]][JS Tests]
-[![Badge Acceptance]][Acceptance Tests] 
-[![Badge Ruby]][Ruby Tests] + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . --- @@ -22,51 +30,13 @@ This project has been funded by the **[DFG]**. [![DFG Logo]][DFG] ---- - -## License - -**Copyright © `2015` - `2022` [Nicole Jung]** 
-of the **[Karlsruhe Institute of Technology]**. - -> This program is free software: -> -> You can redistribute it and / or modify it under the terms
-> of the GNU Affero General Public License as published by
-> the Free Software Foundation, either version 3 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 Affero General Public License for more details. -> -> You should have received a copy of the GNU Affero
-> General Public License along with this program. -> -> If not, see . - +## User Documentation +see online **⸢ [Documentation] ⸥** - -[Installation]: INSTALL.md -[Documentation]: https://www.chemotion.net/chemotionsaurus/docs/eln/about -[Changelog]: CHANGELOG.md - +[Documentation]: https://www.chemotion.net/docs/repo/ [DFG]: https://www.dfg.de/en/ [DFG Logo]: https://www.dfg.de/zentralablage/bilder/service/logos_corporate_design/logo_negativ_267.png - [Nicole Jung]: mailto:nicole.jung@kit.edu [Karlsruhe Institute of Technology]: https://www.kit.edu/english/ - -[JS Tests]: https://github.com/ComPlat/chemotion_ELN/actions/workflows/testjs.yml/badge.svg?branch=development-5 -[Ruby Tests]: https://github.com/ComPlat/chemotion_ELN/actions/workflows/testrb.yml/badge.svg?branch=development-5 -[Acceptance Tests]: https://github.com/ComPlat/chemotion_ELN/actions/workflows/testacceptance.yml/badge.svg?branch=development-5 -[DOI]: https://doi.org/10.5281/zenodo.1054134 - -[Badge JS]: https://github.com/ComPlat/chemotion_ELN/actions/workflows/testjs.yml/badge.svg?branch=development-5 -[Badge Ruby]: https://github.com/ComPlat/chemotion_ELN/actions/workflows/testrb.yml/badge.svg?branch=development-5 -[Badge Acceptance]: https://github.com/ComPlat/chemotion_ELN/actions/workflows/testacceptance.yml/badge.svg?branch=development-5 -[Badge DOI]: https://zenodo.org/badge/DOI/10.5281/zenodo.1054134.svg diff --git a/app/api/api.rb b/app/api/api.rb index 79ec65ecf..5d7259fd5 100644 --- a/app/api/api.rb +++ b/app/api/api.rb @@ -27,11 +27,13 @@ def authenticate! def is_public_request? request.path.start_with?( '/api/v1/public/', - '/api/v1/chemscanner/', + '/api/v1/public_chemscanner/', '/api/v1/chemspectra/', '/api/v1/ketcher/layout', '/api/v1/gate/receiving', - '/api/v1/gate/ping' + '/api/v1/gate/ping', + '/api/v1/search/', + '/api/v1/suggestion' ) end @@ -93,7 +95,7 @@ def to_json_camel_case(val) } TARGET = Rails.env.production? ? 'https://www.chemotion-repository.net/' : 'http://localhost:3000/' - ELEMENTS = %w[research_plan screen wellplate reaction sample] + ELEMENTS = %w[research_plan reaction sample] TEXT_TEMPLATE = %w[SampleTextTemplate ReactionTextTemplate WellplateTextTemplate ScreenTextTemplate ResearchPlanTextTemplate ReactionDescriptionTextTemplate ElementTextTemplate ] @@ -124,7 +126,8 @@ def to_json_camel_case(val) mount Chemotion::DevicesAnalysisAPI mount Chemotion::GateAPI mount Chemotion::ElementAPI - mount Chemotion::ChemScannerAPI + mount Chemotion::PublicChemscannerAPI + mount Chemotion::ChemscannerAPI mount Chemotion::ChemSpectraAPI mount Chemotion::InstrumentAPI mount Chemotion::MessageAPI @@ -142,9 +145,12 @@ def to_json_camel_case(val) mount Chemotion::PrivateNoteAPI mount Chemotion::NmrdbAPI mount Chemotion::ConverterAPI + mount Chemotion::RepositoryAPI + mount Chemotion::ArticleAPI + mount Chemotion::CollaborationAPI add_swagger_documentation(info: { - "title": "Chemotion ELN", + "title": "Chemotion Repository", "version": "1.0" }) if Rails.env.development? end diff --git a/app/api/chemotion/admin_api.rb b/app/api/chemotion/admin_api.rb index 7d0bb8391..837ad4e55 100644 --- a/app/api/chemotion/admin_api.rb +++ b/app/api/chemotion/admin_api.rb @@ -299,6 +299,7 @@ class AdminAPI < Grape::API optional :confirm_user, type: Boolean, desc: 'confirm account' optional :reconfirm_user, type: Boolean, desc: 'reconfirm account' optional :molecule_editor, type: Boolean, desc: 'enable or disable molecule moderation' + optional :converter_admin, type: Boolean, desc: 'enable or disable converter profile' optional :account_active, type: Boolean, desc: 'active or inactive this user' end @@ -336,6 +337,16 @@ class AdminAPI < Grape::API end end + unless params[:converter_admin].nil? + case params[:converter_admin] + when true, false + profile = user.profile + pdata = profile.data || {} + data = pdata.merge('converter_admin' => params[:converter_admin]) + profile.update!(data: data) + end + end + unless params[:molecule_editor].nil? case params[:molecule_editor] when true, false diff --git a/app/api/chemotion/article_api.rb b/app/api/chemotion/article_api.rb new file mode 100644 index 000000000..c678f1ae4 --- /dev/null +++ b/app/api/chemotion/article_api.rb @@ -0,0 +1,187 @@ +require 'moneta' + +module Chemotion + class ArticleAPI < Grape::API + resource :articles do + + helpers do + def resize_image(file, tmp_path, no_resize = false) + image = Magick::Image.read(file[:tempfile].path).first + image = image.resize_to_fit(400, 268) unless no_resize + image.format = 'png' + + FileUtils.mkdir_p(tmp_path) + timg = Tempfile.new('image_', tmp_path) + timg.binmode + timg.write(image.to_blob) + timg.flush + { cover_image: File.basename(timg.path) || '' } + end + + def store_image(params) + sourcepath = params[:public_path] + params[:pfad] + targetpath = params[:public_path] + params[:key] + '_' + params[:pfad] + FileUtils.cp(sourcepath, targetpath) if File.exist?(sourcepath) + end + + def create_or_update_file(params) + key = params[:key] + public_path = params[:public_path] + FileUtils.mkdir_p(public_path) + store = Moneta.build do + use :Transformer, key: [:json], value: [:json] + adapter :File, dir: public_path + end + store_idx = store.key?('index.json') ? store['index.json'] : [] + updated_file = (store_idx&.length > 0 && store_idx&.select{ |a| a['key'] == key }) || [] + # raise '401 Unauthorized' if updated_file&.length > 0 && updated_file[0]['creator_id'] != current_user.id + created_at = updated_file&.length > 0 ? updated_file[0]['created_at'] : Time.now + published_at = params[:published_at].blank? ? created_at : DateTime.parse(params[:published_at]).to_time + updated_at = params[:updated_at].blank? ? published_at : DateTime.parse(params[:updated_at]).to_time + key = updated_file&.length > 0 ? updated_file[0]['key'] : SecureRandom.uuid + store_idx.delete_if { |a| a['key'] == key } + filename = key + '_cover.png' + filepath = public_path + filename + cover_image = params[:cover_image] + if cover_image.present? && cover_image != filename + sourcepath = public_path + cover_image + FileUtils.cp(sourcepath, filepath) if File.exist? sourcepath + end + params[:article].each do |stelle| + next unless stelle['pfad'].present? && (!stelle['pfad'].include?(key)) + store_image({public_path: public_path, key: key, pfad: stelle['pfad']}) + stelle['pfad'] = key + '_' + stelle['pfad'] + end + header = { + key: key, + title: params[:title], + cover_image: cover_image.present? ? filename : '', + creator_name: current_user.name, + creator_id: current_user.id, + created_at: created_at, + firstParagraph: params[:firstParagraph], + published_at: published_at, + updated_at: updated_at + } + store_idx.unshift(header) + store['index.json'] = store_idx + filestore = { + content: params[:content], + title: params[:title], + cover_image: cover_image.present? ? filename : '', + creator_name: current_user.name, + creator_id: current_user.id, + created_at: created_at, + firstParagraph: params[:firstParagraph], + published_at: published_at, + updated_at: updated_at, + article: params[:article], + } + store[key] = filestore + filestore + rescue StandardError => e + puts e + error!('401 Unauthorized. Please contact the author or administrator.', 401) + ensure + store&.close + end + + def delete_file(key, public_path) + FileUtils.mkdir_p(public_path) + store = Moneta.build do + use :Transformer, key: [:json], value: [:json] + adapter :File, dir: public_path + end + store_idx = store.key?('index.json') ? store['index.json'] : [] + error!('404 Not Found', 404) unless store.key?('index.json') + updated_file = (store_idx&.length > 0 && store_idx&.select{ |a| a['key'] == key }) || [] + raise '401 Unauthorized' if updated_file&.length > 0 && updated_file[0]['creator_id'] != current_user.id + + store_idx.delete_if { |a| a['key'] == key } + store['index.json'] = store_idx + FileUtils.rm_r(Dir.glob(public_path + key + '*'), force: true) + key + rescue StandardError => e + puts e + error!('401 Unauthorized. Please contact the author or administrator.', 401) + ensure + store&.close + end + end + + desc 'Create or Update a news' + params do + optional :key, type: String, desc: 'key' + requires :title, type: String, desc: 'title' + optional :cover_image, type: String, desc: 'cover_image URL' + optional :content, type: Hash do + optional :ops, type: Array[Hash] + end + optional :firstParagraph, type: String, desc: 'first paragraph of content' + optional :published_at, type: String, desc: 'published date' + optional :updated_at, type: String, desc: 'updated date' + optional :article, type: Array, desc: 'full of content' + end + + post 'create_or_update' do + error!('401 Unauthorized', 401) unless current_user&.is_article_editor + public_path = File.join((ENV['ARTICLE_PATH'] || 'public/newsroom/')) + create_or_update_file(params.deep_merge(public_path: public_path)) + end + + desc 'Create or Update a howto' + params do + optional :key, type: String, desc: 'howto key' + requires :title, type: String, desc: 'title' + optional :cover_image, type: String, desc: 'cover_image URL' + optional :content, type: Hash do + optional :ops, type: Array[Hash] + end + optional :firstParagraph, type: String, desc: 'first paragraph of content' + optional :published_at, type: String, desc: 'published date' + optional :updated_at, type: String, desc: 'updated date' + optional :article, type: Array, desc: 'full of content' + end + post :create_or_update_howto do + error!('401 Unauthorized', 401) unless current_user&.is_howto_editor + public_path = File.join((ENV['HOWTO_PATH'] || 'public/howto/')) + create_or_update_file(params.deep_merge(public_path: public_path)) + end + + desc 'Delete a howto' + params do + requires :key, type: String, desc: 'howto key' + end + before do + error!('401 Unauthorized', 401) unless current_user&.is_howto_editor + end + post 'delete_howto' do + delete_file(params[:key], File.join((ENV['HOWTO_PATH'] || 'public/howto/'))) + end + + desc 'Delete a news' + before do + error!('401 Unauthorized', 401) unless current_user&.is_article_editor + end + delete ':key' do + delete_file(params[:key], File.join((ENV['ARTICLE_PATH'] || 'public/newsroom/'))) + end + + desc 'Image section of Editor' + params do + requires :file, type: Array, desc: 'image file' + requires :editor_type, type: String, desc: 'howto editor or newsroom editor' + end + post 'editor_image' do + p_path = 'public/' + params[:editor_type] + '/' + e_path = ENV[params[:editor_type].upcase + '_PATH'] + if params[:file] + img = resize_image(params[:file][0], File.join((e_path || p_path)), true) + { pfad_image: img[:cover_image], cover_image: img[:cover_image] } + else + { pfad_image: '', cover_image: '' } + end + end + end + end +end diff --git a/app/api/chemotion/attachment_api.rb b/app/api/chemotion/attachment_api.rb index 20ebcd2c1..42d66478d 100644 --- a/app/api/chemotion/attachment_api.rb +++ b/app/api/chemotion/attachment_api.rb @@ -163,6 +163,13 @@ def validate_uuid_format(uuid) ElementPolicy.new(current_user, element).read? && ElementPermissionProxy.new(current_user, element, user_ids).read_dataset? end + + if !can_dwnld && @attachment.attachable_type == 'SegmentProps' + element = Segment.find(@attachment.attachable_id)&.element + can_dwnld = @attachment.created_for == current_user.id || + (ElementPolicy.new(current_user, element).read? && + ElementPermissionProxy.new(current_user, element, user_ids).read_dataset?) + end end error!('401 Unauthorized', 401) unless can_dwnld end @@ -327,7 +334,7 @@ def validate_uuid_format(uuid) file_text += "#{att.filename} #{att.checksum}\n" end hyperlinks_text = "" - JSON.parse(@container.extended_metadata.fetch('hyperlinks', nil)).each do |link| + JSON.parse(@container.extended_metadata.fetch('hyperlinks', '[]')).each do |link| hyperlinks_text += "#{link} \n" end zip.put_next_entry "dataset_description.txt" @@ -468,6 +475,35 @@ def validate_uuid_format(uuid) end end + desc 'Regenerate edited spectra' + params do + requires :edited, type: Array[Integer] + optional :molfile, type: String + end + post 'regenerate_edited_spectrum' do + pm = to_rails_snake_case(params) + pm[:edited].each do |g_id| + att = Attachment.find(g_id) + next unless att + can_edit = writable?(att) + if can_edit + abs_path = att.abs_path + molfile = pm[:molfile] + result = Tempfile.create('molfile') do |t_molfile| + t_molfile.write(molfile) + t_molfile.rewind + Chemotion::Jcamp::RegenerateJcamp.spectrum( + abs_path, t_molfile.path + ) + end + + att.file_data = result + att.rewrite_file_data! + { status: true } + end + end + end + desc 'Save spectra to file' params do requires :attachment_id, type: Integer @@ -484,6 +520,10 @@ def validate_uuid_format(uuid) optional :predict, type: String optional :keep_pred, type: Boolean optional :waveLength, type: String + optional :cyclicvolta, type: String + optional :curveIdx, type: Integer + optional :simulatenmr, type: Boolean + end post 'save_spectrum' do jcamp_att = @attachment.generate_spectrum( diff --git a/app/api/chemotion/chem_scanner_api.rb b/app/api/chemotion/chem_scanner_api.rb deleted file mode 100644 index 9f0699a91..000000000 --- a/app/api/chemotion/chem_scanner_api.rb +++ /dev/null @@ -1,153 +0,0 @@ -# frozen_string_literal: true - -# Belong to Chemotion module -module Chemotion - require 'open3' - require 'ole/storage' - - # API for ChemScanner manipulation - class ChemScannerAPI < Grape::API - helpers ChemScannerHelpers - format :json - - resource :chemscanner do - resource :embedded do - desc 'Upload import files' - params do - requires :get_mol, type: Boolean, default: false, desc: '' - end - - post 'upload' do - smi_arr = [] - - get_mol = params[:get_mol] - params.delete('get_mol') - - params.each do |uid, file| - temp_file = file['tempfile'] - - file_info = read_uploaded_file(temp_file, get_mol) - unless file_info.nil? || file_info.empty? - smi_obj = { uid: uid, name: file['filename'] }.merge(file_info) - smi_arr.push(smi_obj) - end - - temp_file.close - temp_file.unlink - end - - smi_arr - end - end - - resource :svg do - desc 'Convert svg from MDL and SMILES' - params do - requires :molecules, type: Array, desc: 'Array of molecules that need SVG' - requires :reactions, type: Array, desc: 'Array of reactions that need SVG' - end - - post 'mdl' do - molecules = params[:molecules].map { |m| - { - mid: m[:mid], - svg: Chemotion::OpenBabelService.mdl_to_trans_svg(m[:mdl]) - } - } - - reactions = params[:reactions].map { |r| - info = { - reactants_mdl: (r[:reactants] || []).map { |m| m[:mdl] }, - reagents_mdl: (r[:reagents] || []).map { |m| m[:mdl] }, - products_mdl: (r[:products] || []).map { |m| m[:mdl] }, - reagents_smiles: r[:reagents_smiles].uniq || [] - } - r[:svg] = SVG::ReactionComposer.cs_reaction_svg_from_mdl( - info, - ChemScanner.solvents.values - ) - - r - } - - { - molecules: molecules, - reactions: reactions - } - end - end - - resource :abbreviations do - post 'all' do - { - abbreviations: ChemScanner.all_abbreviations, - superatoms: ChemScanner.all_superatoms - } - end - - params do - requires :newAbb, type: Boolean, desc: 'Abbreviation or superatom' - end - - post 'add' do - if params['newAbb'] - added = ChemScanner.add_abbreviation(params['abb'], params['smiles']) - else - added = ChemScanner.add_superatom(params['abb'], params['smiles']) - ChemScanner.sync_custom_superatom - end - - added - end - - post 'remove' do - abb = params.dig('data', 'abb') - - if params['newAbb'] - removed = ChemScanner.remove_abbreviation(abb) - else - removed = ChemScanner.remove_superatom(abb) - ChemScanner.sync_custom_superatom - end - - removed - end - end - - resource :export do - params do - requires :reactions, type: Array, desc: 'Array of reactions' - requires :molecules, type: Array, desc: 'Array of molecules' - end - - post 'cml' do - reactions = params[:reactions].map { |reaction| - oreactants = reaction[:reactants].map { |r| OpenStruct.new(r) } - oproducts = reaction[:products].map { |r| OpenStruct.new(r) } - - OpenStruct.new( - id: reaction[:id], - reactants: oreactants, - products: oproducts, - reagents: [], - reagent_smiles: reaction[:reagents_smiles], - yield: reaction[:yield], - time: reaction[:time], - temperature: reaction[:temperature], - description: reaction[:description] - ) - } - - rcml = ChemScanner::Export::CML.new(reactions, false).process - molecules = params[:molecules].map { |m| OpenStruct.new(m) } - mcml = ChemScanner::Export::CML.new(molecules, true).process - - { - molecules: mcml, - reactions: rcml - } - end - end - end - end -end diff --git a/app/api/chemotion/chem_spectra_api.rb b/app/api/chemotion/chem_spectra_api.rb index d8a7dbfb6..67f53d704 100644 --- a/app/api/chemotion/chem_spectra_api.rb +++ b/app/api/chemotion/chem_spectra_api.rb @@ -72,6 +72,14 @@ def convert_for_refresh(params) rescue { status: false } end + + def raw_file(att) + begin + Base64.encode64(att.read_file) + rescue StandardError + nil + end + end end resource :chemspectra do # rubocop:disable BlockLength @@ -103,6 +111,8 @@ def convert_for_refresh(params) optional :predict, type: String optional :molfile optional :waveLength, type: String + optional :cyclicvolta, type: String + optional :curveIdx, type: Integer end post 'save' do env['api.format'] = :binary @@ -185,6 +195,24 @@ def convert_for_refresh(params) { smi: m[:smiles], mass: m[:mass], svg: m[:svg], status: true } end end + + resource :nmrium_wrapper do + desc 'Return url of nmrium wrapper' + route_param :host_name do + get do + if Rails.configuration.spectra.nmriumwrapper.blank? + { protocol: '', host: '', port: '' } + else + nmrium_url = Rails.configuration.spectra.nmriumwrapper.url + nmrium_uri = URI(nmrium_url) + protocol = nmrium_uri.scheme + host = nmrium_uri.host + port = nmrium_uri.port == 443 ? '' : nmrium_uri.port + { protocol: protocol, host: host, port: port } + end + end + end + end end end end diff --git a/app/api/chemotion/chemscanner_api.rb b/app/api/chemotion/chemscanner_api.rb new file mode 100644 index 000000000..9c6b94b8d --- /dev/null +++ b/app/api/chemotion/chemscanner_api.rb @@ -0,0 +1,632 @@ +# frozen_string_literal: true + +# Belong to Chemotion module +module Chemotion + require 'open3' + require 'ole/storage' + require 'json' + + # API for ChemScanner manipulation + # rubocop:disable Metric/ClassLength + class ChemscannerAPI < Grape::API + helpers ChemscannerHelpers + format :json + + # rubocop:disable BlockLength + resource :chemscanner do + desc 'Update reaction reagent_smiles' + params do + requires :reactionId, type: Integer, desc: 'Reaction ID' + requires :updateInfo, type: Hash do + requires :add, type: Array[String], desc: 'Smiles to add' + requires :remove, type: Array[String], desc: 'Smiles to remove' + end + end + post 'reagent_smiles' do + reaction_id = params[:reactionId] + + reaction = Chemscanner::Reaction.find_by_id(reaction_id) + return {} if reaction.nil? + + add_smiles = params[:updateInfo][:add].reject(&:empty?) + remove_smiles = params[:updateInfo][:remove] + + reagents = reaction.add_reagent_smiles(add_smiles) + ids = reaction.remove_reagent_smiles(remove_smiles) + reaction.reload + + serialized_reaction = { + id: reaction.id, + svg: build_reaction_svg(reaction), + removedIds: ids, + reagentExternalIds: reaction.reagents.map(&:external_id) + } + + serialized_molecules = reagents.map { |m| + Chemscanner::MoleculeSerializer.new(m).serializable_hash + } + + { reaction: serialized_reaction, molecules: serialized_molecules } + end + + resource :abbreviations do + params do + requires :newAbb, type: Boolean, desc: 'Abbreviation or superatom' + end + + post 'add' do + if params['newAbb'] + added = ChemScanner.add_abbreviation(params['abb'], params['smiles']) + else + added = ChemScanner.add_superatom(params['abb'], params['smiles']) + ChemScanner.sync_custom_superatom + end + + added + end + + post 'remove' do + abb = params.dig('data', 'abb') + + if params['newAbb'] + removed = ChemScanner.remove_abbreviation(abb) + else + removed = ChemScanner.remove_superatom(abb) + ChemScanner.sync_custom_superatom + end + + removed + end + end + end + # rubocop:enable BlockLength + + # rubocop:disable BlockLength + resource :chemscanner_storage do + post 'upload' do + sources = params.reduce([]) do |arr, (uid, file)| + begin + temp_file = file['tempfile'] + + source = Chemscanner::Source.create_from_uploaded_file(file, uid, current_user) + source.save! + next arr.push(source) + rescue StandardError => e + Rails.logger.error("Error while parsing: #{e}") + return { files: [] } + ensure + temp_file.close + temp_file.unlink + end + end + + serialized_sources = sources.map { |s| + Chemscanner::SourceSerializer.new(s).serializable_hash + } + + { files: serialized_sources } + end + + post 'all' do + version = params[:version] + version = Chemscanner::Process::CHEMSCANNER_VERSION if version.nil? + + files = Chemscanner::Source.for_user(current_user.id).order(created_at: :desc) + schemes = Chemscanner::Scheme.where(created_by: current_user.id, version: version) + + reactions = [] + molecules = [] + schemes.each do |scheme| + reactions.concat(scheme.reactions) + molecules.concat(scheme.molecules) + end + + serialize_storage_outputs(files, schemes, reactions, molecules) + end + + resource 'get_outputs' do + params do + requires :ids, type: Array(Integer), desc: 'Item id' + requires :display, type: String, desc: 'Display type: Reaction/Molecule' + end + + post do + type = params['type'] == 'File' ? 'Source' : params['type'] + item_type = "Chemscanner::#{type}" + return {} unless Kernel.const_defined?(item_type) + + klass = item_type.constantize + items = params['ids'].map { |id| klass.find_by_id(id) }.compact + items.select! { |item| item.created_by == current_user.id } + return {} if items.empty? + + files = [] + schemes = [] + reactions = [] + molecules = [] + + if type == 'Scheme' + sources = items.map(&:source).uniq + sources.each do |source| + source_scheme_ids = source.schemes.map(&:id) + next unless (source_scheme_ids - items).empty? + + files.push(source) + end + + schemes.concat(items) + else + items.each do |item| + files.concat([item] + item.children) + item_schemes = item.children.reduce([]) { |arr, child| + arr.concat(child.schemes) + } + item.schemes + schemes.concat(item_schemes) + end + end + + schemes.each do |scheme| + reactions.concat(scheme.reactions) + molecules.concat(scheme.molecules) + end + + serialize_outputs( + files, + schemes, + reactions, + molecules, + params['display'] == 'molecules' + ) + end + end + + resource 'delete' do + params do + requires :ids, type: Array(Integer), desc: 'list id to destroy' + requires :type, type: String, desc: 'type to destroy: Scheme/Source' + requires :version, type: String, desc: 'version to destroy' + end + + post do + type = params['type'] == 'File' ? 'Source' : params['type'] + item_type = "Chemscanner::#{type}" + return [] unless Kernel.const_defined?(item_type) + + klass = item_type.constantize + ids = [] + params['ids'].each do |id| + item = klass.find_by_id(id) + next if item.nil? || item.created_by != current_user.id + + res = item.destroy + ids.push(id) unless res.nil? + end + + Hash[type, ids] + end + end + + resource 'download' do + params do + requires :id, type: Integer, desc: 'list id to destroy' + end + + post do + id = params['id'] + source = Chemscanner::Source.find_by_id(id) + return if source.nil? || source.created_by != current_user.id + + file = source.file + + content_type('application/octet-stream') + header['Content-Disposition'] = 'attachment; filename=' + file.filename + env['api.format'] = :binary + + file.read_file + end + end + + resource 'save_png' do + params do + requires :image_list, type: Array + end + + post do + png_list = params['image_list'] + Chemscanner::Scheme.save_png(png_list) + + true + end + end + + resource 'rescan' do + params do + requires :ids, type: Array(Integer) + end + + post do + files = params['ids'].map { |id| Chemscanner::Source.find_by_id(id) }.compact + files.select! { |file| file.created_by == current_user.id } + return {} if files.empty? + + schemes = [] + new_files = [] + reactions = [] + molecules = [] + + files.each do |file| + new_file = file.schemes.empty? ? file : file.dup + att = new_file.file + att.file_path = att.store.path if att.file_path.nil? + + scanned_schemes = new_file.scan + new_files.concat([new_file] + new_file.children) + schemes.concat(scanned_schemes) + scanned_schemes.each do |scheme| + reactions.concat(scheme.reactions) + molecules.concat(scheme.molecules) + end + end + + new_files.each(&:save) + schemes.each(&:save) + + serialize_storage_outputs(new_files, schemes, reactions, molecules) + end + end + + resource 'scheme_image' do + params do + requires :id, type: Integer, desc: 'Scheme ID' + end + + post do + scheme = Chemscanner::Scheme.find_by_id(params['id']) + return '' if scheme.nil? || scheme.created_by != current_user.id + + { image_data: scheme.image_data } + end + end + + resource 'approve' do + params do + requires :ids, type: Array[Integer], desc: 'Items IDs' + requires :type, type: String, desc: 'Items type' + requires :val, type: Boolean, desc: 'Value to set' + end + + post do + type = params['type'] == 'File' ? 'Source' : params['type'] + item_type = "Chemscanner::#{type}" + return {} unless Kernel.const_defined?(item_type) + + ids = params['ids'] + klass = item_type.constantize + items = ids.map { |id| klass.find_by_id(id) }.compact + items.select! do |item| item.created_by == current_user.id end + return {} if items.empty? + + val = params['val'] + files = [] + schemes = [] + reactions = [] + molecules = [] + + items.each do |item| + info = item.approve(val) + + files.concat(info[:file_ids] || []) + schemes.concat(info[:scheme_ids] || []) + reactions.concat(info[:reaction_ids] || []) + molecules.concat(info[:molecule_ids] || []) + end + + { + val: val, + files: files, + schemes: schemes, + reactions: reactions, + molecules: molecules + } + end + end + + resource 'update_metadata' do + params do + requires :id, type: Integer, desc: 'Item ID' + requires :type, type: String, desc: 'Item type' + requires :data, type: Hash, desc: 'New extended_metadata' + end + + post do + type = params['type'] == 'File' ? 'Source' : params['type'] + item_type = "Chemscanner::#{type}" + return false unless Kernel.const_defined?(item_type) + + klass = item_type.constantize + item = klass.find_by_id(params['id']) + return false if item.nil? || item.created_by != current_user.id + + ext_data = params['data'] + item_ext_data = item.extended_metadata + ext_data.each do |k, v| + item_ext_data[k] = v + end + item.update(extended_metadata: item_ext_data) + + return true + end + end + + resource 'set_archived' do + params do + requires :ids, type: Array, desc: 'Files IDs' + requires :value, type: Boolean, desc: 'Archived value' + end + + post do + string_val = params['value'].to_s + query = 'extended_metadata = ' + query += "JSONB_SET(extended_metadata, '{archived}', '#{string_val}', #{string_val})" + Chemscanner::Source.where(id: params['ids']).update_all(query) + + return true + end + end + + resource 'set_expanded' do + params do + requires :type, type: String, desc: 'Item Type (File or Scheme)' + requires :id, type: Integer, desc: 'Item ID' + requires :value, type: Boolean, desc: 'expanded value' + end + + post do + type = params['type'].to_s + item_type = "Chemscanner::#{type == 'File' ? 'Source' : type}" + value = params['value'] + + item = item_type.constantize.find(params['id']) + item.extended_metadata[:expanded] = value + item.save + end + end + + resource 'update_output' do + params do + requires :id, type: Integer, desc: 'Item ID' + requires :type, type: String, desc: 'Output type' + requires :field, type: String, desc: 'Field to update' + requires :value, type: String, desc: 'Value' + end + + post do + type = params['type'][0..-2].camelize + item_type = "Chemscanner::#{type}" + return false unless Kernel.const_defined?(item_type) + + klass = item_type.constantize + item = klass.find_by_id(params['id']) + return false if item.nil? || item.scheme.created_by != current_user.id + + item.update_attribute(params['field'].to_sym, params['value']) + + return true + end + end + + resource 'import' do + params do + requires :data, type: Array, desc: 'ChemScanner data' + requires :collection, type: Hash, desc: 'Collection to import' + requires :maintainShortLabel, type: Boolean, desc: 'Maintain short label' + end + + post do + collection_info = params['collection'] + cid = collection_info[:id] + new_collection_label = collection_info['newCollection'] + return [] if cid.nil? && new_collection_label.nil? + + uid = current_user.id + new_collection_info = { + user_id: uid, label: new_collection_label + } + + collection = if cid.nil? + Collection.create(new_collection_info) + else + Collection.find_by_id(cid) + end + + data = params['data'] + files = data.select { |item| item['type'] == 'File' } + file_ids = files.map { |f| f['id'] } + schemes = data.select { |item| item['type'] == 'Scheme' } + scheme_ids = schemes.map { |f| f['id'] } + + maintain = params['maintainShortLabel'] + Import::FromChemscanner.from_files_and_schemes( + file_ids, + scheme_ids, + uid, + collection.id, + maintain + ) + + true + end + end + + resource 'toggle_polymer' do + params do + requires :moleculeId, type: Integer, desc: 'Molecule Id' + requires :atomIdx, type: Integer, desc: 'Atom index' + end + + post do + molecule = Chemscanner::Molecule.find_by_id(params['moleculeId']) + return [] if molecule.nil? + + molecule.set_polymer(params['atomIdx']) + end + end + + resource 'fetch_svg' do + params do + requires :moleculeIds, type: Array[Integer], desc: 'Molecule Id' + optional :reactionIds, type: Array[Integer], desc: 'Atom index' + end + + post do + mids = params['moleculeIds'] + msvg_info = [] + rsvg_info = [] + + mids.each do |mid| + m = Chemscanner::Molecule.find_by_id(mid) + next [] if m.nil? + + msvg_info.push( + svg: Chemotion::RdkitService.svg_from_molfile(m.mdl), id: mid + ) + + m.reactions.each do |r| + rsvg_info.push(id: r.id, svg: build_reaction_svg(r)) + end + end + + (params['reactionIds'] || []).each do |id| + r = Chemscanner::Reaction.find_by_id(id) + next [] if r.nil? + + rsvg_info.push(id: r.id, svg: build_reaction_svg(r)) + end + + { molecules: msvg_info, reactions: rsvg_info } + end + end + + resource 'beilstein_export' do + params do + requires :ids, type: Array(Integer), desc: 'list id to destroy' + end + + post do + version = Chemscanner::Process::CHEMSCANNER_VERSION + + list_base64 = [] + params['ids'].each do |id| + source = Chemscanner::Source.find_by_id(id) + filename = source.file.filename + ext_data = source.extended_metadata + + invalid_source = ( + source.nil? || source.created_by != current_user.id || + ext_data.empty? || !filename.end_with?('-article.zip') + ) + next if invalid_source + + invalid_ext = ( + (ext_data['figures'] || []).empty? && + (ext_data['schemes'] || []).empty? + ) + next if invalid_ext + + valid_ext = ext_data.key?('articleId') && ext_data.key?('doi') + next unless valid_ext + + article_id = ext_data['articleId'] + export_ext_data = { + 'articleId' => article_id, + 'doi' => ext_data['doi'] + } + + dir_path = Dir.mktmpdir + source_folder = dir_path + "/#{article_id}" + Dir.mkdir(source_folder) + + sdf_files = [] + %w[figures schemes].each do |key| + key_data = [] + + ext_data[key].each do |data| + identifier = data['graphicsIdentifier'] + + cdx_source = source.children.detect { |s| + cdx_name = s.file.filename + extname = File.extname(cdx_name) + basename = File.basename(cdx_name, '.cdx') + + extname == '.cdx' && basename == identifier + } + next if cdx_source.nil? + + scheme = cdx_source.schemes.find_by_version(version) + basename = File.basename(cdx_source.file.filename, '.cdx') + + structures = [] + scheme.molecules.each_with_index do |mol, idx| + next unless mol.is_approved + + inchi, inchikey = OpenBabelService.inchi_info_from_molfile(mol.mdl) + next if inchi.empty? || inchikey.empty? + + sdf_filename = "#{basename}-#{idx + 1}.sdf" + sdf_files.push(sdf_filename) + + sdf_content = [ + mol.mdl, + "\n> \n#{inchi}", + "\n> \n#{inchikey}" + ].join("\n") + f = File.new("#{source_folder}/#{sdf_filename}", 'w+') + f.write(sdf_content) + f.close + + structures.push( + 'sequenceNumber' => idx + 1, + 'InChI' => inchi, + 'InChIKey' => inchikey, + 'SDF' => "#{basename}-#{idx + 1}.sdf" + ) + end + + key_data.push( + 'sequenceNumber' => data['sequenceNumber'], + 'graphicsIdentifier' => identifier, + 'structures' => structures + ) + end + + export_ext_data[key] = key_data + end + + json_path = "#{source_folder}/#{article_id}.json" + json_file = File.new(json_path, 'w+') + json_file.write(export_ext_data.to_json) + json_file.close + + zip_filename = "#{dir_path}/#{article_id}-structures.zip" + Zip::File.open(zip_filename, Zip::File::CREATE) do |zipfile| + zipfile.add("#{article_id}.json", json_path) + + sdf_files.each do |sdf_filename| + zipfile.add(sdf_filename, "#{source_folder}/#{sdf_filename}") + end + end + + list_base64.push( + b64: Base64.encode64(File.read(zip_filename)), + name: "#{article_id}-structures.zip" + ) + end + + { files: list_base64 } + end + end + end + # rubocop:enable BlockLength + end + # rubocop:enable Metric/ClassLength +end diff --git a/app/api/chemotion/collaboration_api.rb b/app/api/chemotion/collaboration_api.rb new file mode 100644 index 000000000..7f49c4444 --- /dev/null +++ b/app/api/chemotion/collaboration_api.rb @@ -0,0 +1,276 @@ +# frozen_string_literal: true + +module Chemotion + class CollaborationAPI < Grape::API + resource :collaborators do + helpers do + def format_date(obj) + return nil if obj.nil? || obj['year'].nil? + + Date.new(obj['year']&.to_i, obj['month']&.to_i || 1, obj['day']&.to_i || 1) + end + + def process_orcid_affiliation(user, emps) + return if emps.nil? + + emps = [emps] unless emps.is_a?(Array) + emps.each do |emp| + next if emp.nil? + + org = emp['employment_summary']['organization']['name'] + c_code = emp['employment_summary']['organization']['address']['country'] + dep = emp['employment_summary']['department_name'] + country = ISO3166::Country.translations[c_code] + from = format_date(emp['employment_summary']['start_date']) + to = format_date(emp['employment_summary']['end_date']) + aff = Affiliation.find_or_create_by(country: country, organization: org, department: dep) + UserAffiliation.create(user_id: user.id, affiliation_id: aff.id, from: from, to: to) + end + end + end + + namespace :list do + desc 'fetch collaborators of current user' + get do + ids = UsersCollaborator.where(user_id: current_user.id).pluck(:collaborator_id) + data = User.where(id: ids) + present data, with: Entities::CollaboratorEntity, root: 'authors' + end + end + + namespace :orcid do + desc 'fetch collaborators by orcid' + params do + requires :orcid, type: String + end + get do + erro_msg = nil + collaborator = User.where(type: %w[Person Collaborator]).where(["providers->>'orcid' = ?", params[:orcid]]).order('type desc').first + + if collaborator.nil? + result = Chemotion::OrcidService.record_person(params[:orcid]) + ## byebug ### PAGGY + if result.nil? + erro_msg = 'ORCID does not exist! Please check.' + elsif result.person&.family_name.nil? + erro_msg = 'Last name can not be blank!' + else + attributes = {} + attributes[:first_name] = result&.person&.given_names + attributes[:last_name] = result.person&.family_name + attributes[:type] = 'User' + attributes[:confirmed_at] = DateTime.now + attributes[:name_abbreviation] = "c#{SecureRandom.random_number(9999)}" + attributes[:password] = Devise.friendly_token.first(8) + # attributes[:email] = "#{current_user.name_abbreviation}.#{attributes[:name_abbreviation]}@chemotion.net" + attributes[:email] = result.person&.email + + if result.person&.email.present? + collaborator = User.where(email: result.person&.email).first + end + if collaborator.nil? + collaborator = User.new(attributes) + emps = Chemotion::OrcidService.record_employments(params[:orcid]) + emps = [emps] unless emps.is_a?(Array) + if emps.present? && emp = emps&.first + org = emp['employment_summary']['organization']['name'] + c_code = emp['employment_summary']['organization']['address']['country'] + dep = emp['employment_summary']['department_name'] + country = ISO3166::Country.translations[c_code] + from = format_date(emp['employment_summary']['start_date']) + to = format_date(emp['employment_summary']['end_date']) + aff = Affiliation.new(country: country, organization: org, department: dep) + collaborator.affiliations = [aff] + end + end + end + elsif collaborator.id == current_user.id + erro_msg = 'Can not add yourself as a collaborator' + else + has_col = UsersCollaborator.where(user_id: current_user.id, collaborator_id: collaborator.id) + if has_col.empty? + UsersCollaborator.create(user_id: current_user.id, collaborator_id: collaborator.id) + else + erro_msg = 'Collaborator is existed!' + end + end + + if erro_msg.nil? + # ids = UsersCollaborator.where(user_id: current_user.id).pluck(:collaborator_id) + # data = User.where(id: ids) + # present data, with: Entities::CollaboratorEntity, root: 'authors' + present collaborator, with: Entities::CollaboratorEntity, root: 'users' + else + { error: true, message: erro_msg } + end + end + end + + namespace :user do + desc 'fetch collaborators of current user' + params do + optional :name, type: String + optional :first, type: String + optional :email, type: String + end + get do + scope = User.where.not(confirmed_at: nil).where(type: 'Person') + scope = scope.where([" LOWER(first_name) LIKE ? ",'%'+params[:first].downcase+'%']) if params[:first].present? + scope = scope.where([" LOWER(last_name) LIKE ? ",'%'+params[:name].downcase+'%']) if params[:name].present? + scope = scope.where([" LOWER(email) LIKE ? ",'%'+params[:email].downcase+'%']) if params[:email].present? + present scope.limit(5), with: Entities::CollaboratorEntity, root: 'users' + end + end + + namespace :add do + desc 'add user to my collabration' + params do + requires :id, type: Integer + end + post do + new_author = UsersCollaborator.create({ user_id: current_user.id, collaborator_id: params[:id] }) + user = User.find(params[:id]) + present user, with: Entities::CollaboratorEntity, root: 'user' + end + end + + namespace :add_aff do + desc 'add user to my collabration' + params do + requires :id, type: Integer + requires :department, type: String + requires :organization, type: String + requires :country, type: String + end + post do + collaborator = User.find(params[:id]) + aff = [Affiliation.find_or_create_by(country: params[:country], organization: params[:organization], department: params[:department])] + collaborator.affiliations << aff unless aff.nil? + present collaborator, with: Entities::CollaboratorEntity, root: 'user' + end + end + + namespace :find_add_aff do + desc 'add user to my collabration' + params do + requires :department, type: String + requires :organization, type: String + requires :country, type: String + end + post do + aff = Affiliation.find_or_create_by(country: params[:country], organization: params[:organization], department: params[:department]) + { id: aff.id, aff_output: aff.output_full } + end + end + + namespace :delete do + desc 'remove user from my collabration' + params do + requires :id, type: Integer + end + post do + uc = UsersCollaborator.find_by(user_id: current_user.id, collaborator_id: params[:id]) + uc.delete + #present user, with: Entities::CollaboratorEntity, root: 'user' + end + end + + namespace :delete_aff do + desc 'remove affilication from my collabration' + params do + requires :user_id, type: Integer + requires :aff_id, type: Integer + end + post do + ua = UserAffiliation.find_by(user_id: params[:user_id], affiliation_id: params[:aff_id]) + ua.destroy! + + user = User.find(params[:user_id]) + present user, with: Entities::CollaboratorEntity, root: 'user' + end + end + + namespace :refresh_orcid_aff do + desc 'refresh affilication from orcid' + params do + requires :user_id, type: Integer + end + post do + user = User.find_by(id: params[:user_id]) + if user.type != 'Collaborator' || user.orcid.nil? + { error: true, message: 'Unable to refresh the affilication from this ORCID!' } + else + emps = Chemotion::OrcidService.record_employments(user.orcid) + if emps.nil? + { error: true, message: 'Unable to fetch the affilication from this ORCID!' } + else + uas = UserAffiliation.where(user_id: user.id) + uas.each do |ua| + next if ua.nil? + + ua.destroy! + end + process_orcid_affiliation(user, emps) + ids = UsersCollaborator.where(user_id: current_user.id).pluck(:collaborator_id) + data = User.where(id: ids) + present data, with: Entities::CollaboratorEntity, root: 'authors' + end + end + end + end + + namespace :load_orcid do + desc 'refresh affilication from orcid' + params do + requires :ids, type: Array[Integer] + end + post do + orcids = User.where(id: params[:ids]).map { |user| { id: user.id, orcid: user.orcid } } + { orcids: orcids.reject { |oo| oo[:orcid].nil? } } + end + end + + namespace :create do + get_name_abbr = Proc.new do |first_name, last_name, cnt| + name_abbr = "#{first_name.first.capitalize}#{last_name.first.capitalize}#{cnt}" + if User.find_by(name_abbreviation: name_abbr).nil? + name_abbr + else + get_name_abbr.call(first_name, last_name, cnt+1) + end + end + + desc 'create and add user to my collabration' + params do + requires :lastName, type: String + requires :firstName, type: String + requires :email, type: String + optional :orcid, type: String + requires :department, type: String + requires :organization, type: String + requires :country, type: String + end + post do + attributes = {} #declared(params, include_missing: false) + attributes[:first_name] = params[:firstName] + attributes[:last_name] = params[:lastName] + attributes[:type] = 'Person' + attributes[:confirmed_at] = DateTime.now + attributes[:name_abbreviation] = get_name_abbr.call(params[:firstName], params[:lastName], 1) + attributes[:password] = Devise.friendly_token.first(8) + attributes[:email] = params[:email] + attributes[:providers] = { orcid: params[:orcid] } + new_user = User.create!(attributes) + new_user.profile.update!({data: {}}) + new_user.affiliations = [Affiliation.find_or_create_by(country: params[:country], + organization: params[:organization], department: params[:department])] + + new_author = UsersCollaborator.create({ user_id: current_user.id, collaborator_id: new_user.id }) + present new_user, with: Entities::CollaboratorEntity, root: 'user' + rescue StandardError => e + { error: true, message: e.message } + end + end + end + end +end diff --git a/app/api/chemotion/collection_api.rb b/app/api/chemotion/collection_api.rb index d5f76ede4..7e7e6b197 100644 --- a/app/api/chemotion/collection_api.rb +++ b/app/api/chemotion/collection_api.rb @@ -11,6 +11,20 @@ class CollectionAPI < Grape::API end end + namespace :all_as_tree do + desc "Return the 'All' collection of the current user" + get do + current_user.collections.arrange_serializable do |parent, children| + { + title: parent.label, + value: parent.id, + key: parent.id, + children: children + } + end + end + end + desc "Return collection by id" params do requires :id, type: Integer, desc: "Collection id" @@ -39,8 +53,12 @@ class CollectionAPI < Grape::API desc "Return all locked and unshared serialized collection roots of current user" get :locked do - current_user.collections.includes(:shared_users) + if (current_user.type == 'Anonymous') + [] + else + current_user.collections.includes(:shared_users) .locked.unshared.roots.order('label ASC') + end end get_child = Proc.new do |children, collects| @@ -129,7 +147,8 @@ class CollectionAPI < Grape::API end put ':id' do - Collection.shared(current_user.id).find(params[:id]).update!(params[:collection_attributes]) + declared_params = declared(params, include_missing: false) + Collection.shared(current_user.id).find(declared_params[:id]).update!(declared_params[:collection_attributes]) end desc "Create shared collections" @@ -393,19 +412,18 @@ class CollectionAPI < Grape::API namespace :exports do desc "Create export job" params do - requires :collections, type: Array[Integer] + optional :collections, type: Array[Integer] + optional :sync_collections, type: Array[Integer] requires :format, type: Symbol, values: [:json, :zip, :udm] requires :nested, type: Boolean end post do - collection_ids = params[:collections].uniq + collection_ids = params[:collections]&.uniq + sync_col_ids = params[:sync_collections]&.uniq nested = params[:nested] == true - if collection_ids.empty? - # no collection was given, export all collections for this user - collection_ids = Collection.belongs_to_or_shared_by(current_user.id, current_user.group_ids).pluck(:id) - else + if collection_ids.present? # check if the user is allowed to export these collections collection_ids.each do |collection_id| collection = Collection.belongs_to_or_shared_by(current_user.id, current_user.group_ids).find_by(id: collection_id) @@ -417,7 +435,15 @@ class CollectionAPI < Grape::API end end - ExportCollectionsJob.perform_later(collection_ids, params[:format].to_s, nested, current_user.id) + if sync_col_ids.present? + sync_col_ids.each do |sync_col_id| + sync_col = SyncCollectionsUser.find_by(id: sync_col_id, user_id: current_user.id) + col = Collection.find(sync_col.collection_id) if sync_col.present? + collection_ids << sync_col.collection_id if col.present? && col.parent.present? && ['My Published Elements', 'Published Elements', 'Embargoed Publications'].include?(col.parent&.label) + end + end + + ExportCollectionsJob.perform_later(collection_ids, params[:format].to_s, nested, current_user.id) unless collection_ids.empty? status 204 end end diff --git a/app/api/chemotion/converter_api.rb b/app/api/chemotion/converter_api.rb index 9f898a8c3..c5f14e697 100644 --- a/app/api/chemotion/converter_api.rb +++ b/app/api/chemotion/converter_api.rb @@ -7,7 +7,6 @@ class ConverterAPI < Grape::API resource :converter do resource :profiles do before do - error!(401) unless current_user.is_a?(Admin) @conf = Rails.configuration.try(:converter).try(:url) @profile = Rails.configuration.try(:converter).try(:profile) error!(406) unless @conf && @profile @@ -36,9 +35,23 @@ class ConverterAPI < Grape::API end end + resource :options do + before do + error!(401) unless current_user.profile&.data['converter_admin'] == true + @conf = Rails.configuration.try(:converter).try(:url) + @profile = Rails.configuration.try(:converter).try(:profile) + error!(406) unless @conf && @profile + end + desc 'fetch options' + get do + options = Analyses::Converter.fetch_options + { options: options, client: @profile } + end + end + resource :tables do before do - error!(401) unless current_user.is_a?(Admin) + error!(401) unless current_user.profile&.data['converter_admin'] == true @conf = Rails.configuration.try(:converter).try(:url) @profile = Rails.configuration.try(:converter).try(:profile) error!(406) unless @conf && @profile diff --git a/app/api/chemotion/element_api.rb b/app/api/chemotion/element_api.rb index ce3814fd6..96b711c59 100644 --- a/app/api/chemotion/element_api.rb +++ b/app/api/chemotion/element_api.rb @@ -76,7 +76,17 @@ class ElementAPI < Grape::API deleted = { 'sample' => [] } %w[sample reaction wellplate screen research_plan].each do |element| next unless params[element][:checkedAll] || params[element][:checkedIds].present? - deleted[element] = @collection.send(element + 's').by_ui_state(params[element]).destroy_all.map(&:id) + elements = @collection.send(element + 's').by_ui_state(params[element]) + + elements.each do |el| + pub = el.publication + + next if pub.nil? + pub.update_state(Publication::STATE_DECLINED) + pub.process_element(Publication::STATE_DECLINED) + pub.inform_users(Publication::STATE_DECLINED, current_user.id) + end + deleted[element] = elements.destroy_all.map(&:id) end # explicit inner join on reactions_samples to get soft deleted reactions_samples entries @@ -90,18 +100,32 @@ class ElementAPI < Grape::API deleted[klass.name] = @collection.send('elements').by_ui_state(params[klass.name]).destroy_all.map(&:id) end + sql_pub = "(element_id in (?) and element_type = 'Sample') or (element_id in (?) and element_type = 'Reaction')" + Publication.where(sql_pub, deleted['sample'], deleted['reaction']) + .map(&:root).uniq.each do |e| + e.update_state(Publication::STATE_DECLINED) + e.proces_element(Publication::STATE_DECLINED) + e.inform_users(Publication::STATE_DECLINED, current_user.id) + end { selecteds: params[:selecteds].select { |sel| !deleted.fetch(sel['type'], []).include?(sel['id']) } } end desc "return selected elements from the list. (only samples an reactions)" post do + selected = { 'samples' => [], 'reactions' => [] } - %w[sample reaction].each do |element| - next unless params[element][:checkedAll] || params[element][:checkedIds].present? - selected[element + 's'] = @collection.send(element + 's').by_ui_state(params[element]).map do |e| - ElementPermissionProxy.new(current_user, e, user_ids).serialized - end + + @collection_ids = [@collection.id] + Collection.joins(:sync_collections_users) + .where('sync_collections_users.collection_id = collections.id and sync_collections_users.user_id = ?', current_user).references(:collections)&.pluck(:id) + + selected['samples'] = Sample.joins(:collections_samples).where('collections_samples.collection_id in (?)',@collection_ids).by_ui_state(params['sample']).distinct.map do |e| + ElementPermissionProxy.new(current_user, e, user_ids).serialized end + + selected['reactions'] = Reaction.joins(:collections_reactions).where('collections_reactions.collection_id in (?)',@collection_ids).by_ui_state(params['reaction']).distinct.map do |e| + ElementPermissionProxy.new(current_user, e, user_ids).serialized + end + # TODO: fallback if sample are not in owned collection and currentCollection is missing # (case when cloning report) selected diff --git a/app/api/chemotion/gate_api.rb b/app/api/chemotion/gate_api.rb index 97d49bae8..d92c4cc76 100644 --- a/app/api/chemotion/gate_api.rb +++ b/app/api/chemotion/gate_api.rb @@ -5,6 +5,7 @@ class UriHTTPType def self.parse(value) URI.parse value end + def self.parsed?(value) value.is_a? URI::HTTP end @@ -83,7 +84,6 @@ def self.parsed?(value) end @url = @jwt.fqdn # fqdn should actually be eq to an orgin (proto/host/port) @req_headers = { 'Authorization' => "Bearer #{@jwt.token}", 'Origin' => request.headers['Referer'] } - @queue = "gate_transfer_#{@collection.id}" @move_queue = "move_to_collection_#{@collection.id}" # TODO: use persistent connection connection = Faraday.new(url: @url) do |f| @@ -104,10 +104,7 @@ def self.parsed?(value) end post do - Delayed::Job.where(queue: @queue).destroy_all - Delayed::Job.where(queue: @move_queue).destroy_all - GateTransferJob.set(queue: @queue) - .perform_later(@collection.id, @url, @req_headers) + TransferRepoJob.perform_later(@collection.id, current_user.id, @url, @req_headers) status 202 end @@ -172,6 +169,78 @@ def self.parsed?(value) end end + + desc <<~DESC + receive sample and reaction data from a remote eln and import them into a designated + collection according to JWT info. (authentication through JWT) + DESC + namespace :receiving_zip do + params do + requires :data, type: File + end + + before do + http_token = if request.headers['Authorization'].present? + request.headers['Authorization'].split(' ').last + end + error!('Unauthorized', 401) unless http_token + secret = Rails.application.secrets.secret_key_base + begin + @auth_token = HashWithIndifferentAccess.new( + JWT.decode(http_token, secret)[0] + ) + rescue JWT::VerificationError, JWT::DecodeError, JWT::ExpiredSignature => e + error!("#{e}", 401) + end + @user = Person.find_by(email: @auth_token[:iss]) + error!('Unauthorized', 401) unless @user + @collection = Collection.find_by( + id: @auth_token[:collection], user_id: @user.id, is_shared: false + ) + error!('Unauthorized access to collection', 401) unless @collection + + @origin = @auth_token["origin"] + end + + post do + db_file = params[:data]&.fetch('tempfile', nil) + # file = params[:file] + tempfile = db_file + att = Attachment.new( + filename: params[:data][:filename], + key: File.basename(tempfile.path), + file_path: tempfile, + created_by: @user.id, + created_for: @user.id, + content_type: 'application/zip' + ) + begin + att.save! + ensure + tempfile.close + tempfile.unlink + end + + begin + import = Import::ImportCollections.new(att, @user.id, true, @collection.id, @origin) + import.extract + import.import! + rescue => e + Delayed::Worker.logger.error e + Message.create_msg_notification( + channel_subject: Channel::COLLECTION_ZIP_FAIL, + message_from: @user&.id, + data_args: { col_labels: '', operation: 'import' }, + autoDismiss: 5 + ) + @success = false + ensure + att&.destroy! + end + status(200) + end + end + namespace :register_gate do params do requires :collection_id, type: Integer, desc: 'Collection id' @@ -252,6 +321,41 @@ def self.parsed?(value) { jwt: token } end end + + namespace :register_eln do + params do + requires :origin, type: UriHTTPType, desc: 'remote eln adress' + end + + after_validation do + error!('401 Unauthorized - no ELN Gate collection', 401) unless (@collec = Collection.find_by( + user_id: current_user.id, is_locked: true, label: 'ELN Gate' + )) + end + + post do + origin = URI.join(params[:origin], '/').to_s + payload = { + collection: @collec.id, + # label: @collec.label[0..20], + iss: current_user.email, + exp: (Time.now + 28.days).to_i, + origin: origin + } + secret = Rails.application.secrets.secret_key_base + token = JWT.encode payload, secret + AuthenticationKey.create!( + user_id: current_user.id, + fqdn: origin, + role: 'gate in', + token: token + ) + # TODO: add a boolean on collection to allow AuthenticationKey + # or use sync_collections_users ?? + redirect(URI.join(origin, "/api/v1/gate/register_repo?token=#{token}").to_s) + end + end + end end end diff --git a/app/api/chemotion/generic_dataset_api.rb b/app/api/chemotion/generic_dataset_api.rb index 843786777..079665e80 100644 --- a/app/api/chemotion/generic_dataset_api.rb +++ b/app/api/chemotion/generic_dataset_api.rb @@ -10,6 +10,18 @@ class GenericDatasetAPI < Grape::API present list.sort_by(&:place), with: Entities::DatasetKlassEntity, root: 'klass' end end + + namespace :list_dataset_klass do + desc 'list Generic Dataset Klass' + params do + optional :is_active, type: Boolean, desc: 'Active or Inactive Dataset' + end + get do + list = DatasetKlass.where(is_active: params[:is_active]) if params[:is_active].present? + list = DatasetKlass.all unless params[:is_active].present? + present list.sort_by(&:place), with: Entities::DatasetKlassEntity, root: 'klass' + end + end end end end diff --git a/app/api/chemotion/generic_element_api.rb b/app/api/chemotion/generic_element_api.rb index 29afc217f..8f996e27a 100644 --- a/app/api/chemotion/generic_element_api.rb +++ b/app/api/chemotion/generic_element_api.rb @@ -33,6 +33,86 @@ class GenericElementAPI < Grape::API end end + namespace :create_element_klass do + desc 'create Generic Element Klass' + params do + requires :name, type: String, desc: 'Element Klass Name' + requires :label, type: String, desc: 'Element Klass Label' + requires :klass_prefix, type: String, desc: 'Element Klass Short Label Prefix' + optional :icon_name, type: String, desc: 'Element Klass Icon Name' + optional :desc, type: String, desc: 'Element Klass Desc' + optional :properties_template, type: Hash, desc: 'Element Klass properties template' + end + post do + authenticate_admin!('elements') + uuid = SecureRandom.uuid + template = { uuid: uuid, layers: {}, select_options: {} } + attributes = declared(params, include_missing: false) + attributes[:properties_template]['uuid'] = uuid if attributes[:properties_template].present? + attributes[:properties_template] = template unless attributes[:properties_template].present? + attributes[:properties_template]['eln'] = Chemotion::Application.config.version if attributes[:properties_template].present? + attributes[:properties_template]['klass'] = 'ElementKlass' if attributes[:properties_template].present? + attributes[:is_active] = false + attributes[:uuid] = uuid + attributes[:released_at] = DateTime.now + attributes[:properties_release] = attributes[:properties_template] + attributes[:created_by] = current_user.id + + new_klass = ElementKlass.create!(attributes) + new_klass.reload + new_klass.create_klasses_revision(current_user.id) + klass_names_file = Rails.root.join('config', 'klasses.json') + klasses = ElementKlass.where(is_active: true)&.pluck(:name) || [] + File.write(klass_names_file, klasses) + + status 201 + rescue ActiveRecord::RecordInvalid => e + { error: e.message } + end + end + + namespace :update_element_klass do + desc 'update Generic Element Klass' + params do + requires :id, type: Integer, desc: 'Element Klass ID' + optional :label, type: String, desc: 'Element Klass Label' + optional :klass_prefix, type: String, desc: 'Element Klass Short Label Prefix' + optional :icon_name, type: String, desc: 'Element Klass Icon Name' + optional :desc, type: String, desc: 'Element Klass Desc' + optional :place, type: String, desc: 'Element Klass Place' + end + post do + authenticate_admin!('elements') + place = params[:place] || 100 + begin + place = place.to_i if place.present? && place.to_i == place.to_f + rescue StandardError + place = 100 + end + klass = ElementKlass.find(params[:id]) + klass.label = params[:label] if params[:label].present? + klass.klass_prefix = params[:klass_prefix] if params[:klass_prefix].present? + klass.icon_name = params[:icon_name] if params[:icon_name].present? + klass.desc = params[:desc] if params[:desc].present? + klass.place = place + klass.save! + klass + end + end + + namespace :klass_revisions do + desc 'list Generic Element Revisions' + params do + requires :id, type: Integer, desc: 'Generic Element Klass Id' + requires :klass, type: String, desc: 'Klass', values: %w[ElementKlass SegmentKlass DatasetKlass] + end + get do + klass = params[:klass].constantize.find_by(id: params[:id]) + list = klass.send("#{params[:klass].underscore}es_revisions") unless klass.nil? + present list.sort_by(&:released_at).reverse, with: Entities::KlassRevisionEntity, root: 'revisions' + end + end + namespace :element_revisions do desc 'list Generic Element Revisions' params do @@ -45,8 +125,26 @@ class GenericElementAPI < Grape::API end end + namespace :delete_klass_revision do + desc 'delete Klass Revision' + params do + requires :id, type: Integer, desc: 'Revision ID' + requires :klass_id, type: Integer, desc: 'Klass ID' + requires :klass, type: String, desc: 'Klass', values: %w[ElementKlass SegmentKlass DatasetKlass] + end + post do + authenticate_admin!(params[:klass].gsub(/(Klass)/, 's').downcase) + revision = "#{params[:klass]}esRevision".constantize.find(params[:id]) + klass = params[:klass].constantize.find_by(id: params[:klass_id]) unless revision.nil? + error!('Revision is invalid.', 404) if revision.nil? + error!('Can not delete the active revision.', 405) if revision.uuid == klass.uuid + revision&.destroy! + status 201 + end + end + namespace :delete_revision do - desc 'list Generic Element Revisions' + desc 'delete Generic Element Revisions' params do requires :id, type: Integer, desc: 'Revision Id' requires :element_id, type: Integer, desc: 'Element ID' @@ -91,13 +189,13 @@ class GenericElementAPI < Grape::API post do attach_ary = [] att_ary = create_uploads('Element', params[:att_id], params[:elfiles], params[:elInfo], current_user.id) if params[:elfiles].present? && params[:elInfo].present? - (attach_ary << att_ary).flatten! unless att_ary&.empty? + (attach_ary << att_ary).flatten! unless att_ary.blank? att_ary = create_uploads('Segment', params[:att_id], params[:sefiles], params[:seInfo], current_user.id) if params[:sefiles].present? && params[:seInfo].present? - (attach_ary << att_ary).flatten! unless att_ary&.empty? + (attach_ary << att_ary).flatten! unless att_ary.blank? att_ary = create_attachments(params[:attfiles], params[:delfiles], params[:att_type], params[:att_id], current_user.id) if params[:attfiles].present? || params[:delfiles].present? - (attach_ary << att_ary).flatten! unless att_ary&.empty? - TransferThumbnailToPublicJob.set(queue: "transfer_thumbnail_to_public_#{current_user.id}").perform_later(attach_ary) unless attach_ary.empty? - TransferFileFromTmpJob.set(queue: "transfer_file_from_tmp_#{current_user.id}").perform_later(attach_ary) unless attach_ary.empty? + (attach_ary << att_ary).flatten! unless att_ary.blank? + TransferThumbnailToPublicJob.set(queue: "transfer_thumbnail_to_public_#{current_user.id}").perform_now(attach_ary) unless attach_ary.empty? + TransferFileFromTmpJob.set(queue: "transfer_file_from_tmp_#{current_user.id}").perform_now(attach_ary) unless attach_ary.empty? true end end @@ -110,6 +208,67 @@ class GenericElementAPI < Grape::API end end + namespace :de_activate_klass do + desc 'activate or deactivate Generic Klass' + params do + requires :klass, type: String, desc: 'Klass', values: %w[ElementKlass SegmentKlass DatasetKlass] + requires :id, type: Integer, desc: 'Klass ID' + requires :is_active, type: Boolean, desc: 'Active or Inactive Klass' + end + after_validation do + authenticate_admin!(params[:klass].gsub(/(Klass)/, 's').downcase) + @klz = fetch_klass(params[:klass], params[:id]) + end + post do + @klz&.update!(is_active: params[:is_active]) + generate_klass_file unless @klz.class.name != 'ElementKlass' + + @klz + end + end + + namespace :delete_klass do + desc 'delete Generic Klass' + params do + requires :klass, type: String, desc: 'Klass', values: %w[ElementKlass SegmentKlass DatasetKlass] + requires :id, type: Integer, desc: 'Klass ID' + end + delete ':id' do + authenticate_admin!(params[:klass].gsub(/(Klass)/, 's').downcase) + klass = fetch_klass(params[:klass], params[:id]) + klass&.destroy! + generate_klass_file unless klass.class.name != 'ElementKlass' + status 201 + end + end + + namespace :update_template do + desc 'update Generic Properties Template' + params do + requires :klass, type: String, desc: 'Klass', values: %w[ElementKlass SegmentKlass DatasetKlass] + requires :id, type: Integer, desc: 'Klass ID' + requires :properties_template, type: Hash + optional :is_release, type: Boolean, default: false + end + after_validation do + authenticate_admin!(params[:klass].gsub(/(Klass)/, 's').downcase) + @klz = fetch_klass(params[:klass], params[:id]) + end + post do + uuid = SecureRandom.uuid + properties = params[:properties_template] + properties['uuid'] = uuid + properties['eln'] = Chemotion::Application.config.version + properties['klass'] = @klz.class.name + + @klz.properties_template = properties + @klz.save! + @klz.reload + @klz.create_klasses_revision(current_user.id) if params[:is_release] == true + @klz + end + end + desc 'Return serialized elements of current user' params do optional :collection_id, type: Integer, desc: 'Collection id' @@ -175,6 +334,7 @@ class GenericElementAPI < Grape::API requires :element_klass, type: Hash requires :name, type: String optional :properties, type: Hash + optional :properties_release, type: Hash optional :collection_id, type: Integer requires :container, type: Hash optional :segments, type: Array, desc: 'Segments' @@ -186,12 +346,16 @@ class GenericElementAPI < Grape::API params[:properties]['klass_uuid'] = klass[:uuid] params[:properties]['eln'] = Chemotion::Application.config.version params[:properties]['klass'] = 'Element' + properties = params[:properties] + properties.delete('flow') unless properties['flow'].nil? + properties.delete('select_options') unless properties['select_options'].nil? attributes = { name: params[:name], element_klass_id: klass[:id], uuid: uuid, klass_uuid: klass[:uuid], - properties: params[:properties], + properties: properties, + properties_release: params[:properties_release], created_by: current_user.id } element = Element.new(attributes) @@ -216,7 +380,8 @@ class GenericElementAPI < Grape::API params do requires :id, type: Integer, desc: 'element id' optional :name, type: String - optional :properties, type: Hash + requires :properties, type: Hash + optional :properties_release, type: Hash requires :container, type: Hash optional :segments, type: Array, desc: 'Segments' end @@ -239,6 +404,10 @@ class GenericElementAPI < Grape::API properties['klass'] = 'Element' uuid = SecureRandom.uuid properties['uuid'] = uuid + + properties.delete('flow') unless properties['flow'].nil? + properties.delete('select_options') unless properties['select_options'].nil? + attributes['properties'] = properties attributes['properties']['uuid'] = uuid attributes['uuid'] = uuid diff --git a/app/api/chemotion/inbox_api.rb b/app/api/chemotion/inbox_api.rb index 186a933ad..252ba4138 100644 --- a/app/api/chemotion/inbox_api.rb +++ b/app/api/chemotion/inbox_api.rb @@ -7,21 +7,21 @@ class InboxAPI < Grape::API requires :search_string, type: String, desc: 'Search String' end get do - search_string = params[:search_string] - search_string.chomp!(File.extname(search_string)) - search_string.chomp!(' EA') - search_string.sub!(/-?[a-zA-Z]$/, '') - search_string.sub!(/^[a-zA-Z0-9]+-/, '') - collection_ids = Collection.belongs_to_or_shared_by(current_user.id, current_user.group_ids).map(&:id) - samples = Sample.by_exact_name(search_string).joins(:collections_samples).where('collections_samples.collection_id in (?)', collection_ids).uniq - samples.select { |s| ElementPolicy.new(current_user, s).update? } + # search_string = params[:search_string] + # search_string.chomp!(File.extname(search_string)) + # search_string.chomp!(' EA') + # search_string.sub!(/-?[a-zA-Z]$/, '') + # search_string.sub!(/^[a-zA-Z0-9]+-/, '') + # collection_ids = Collection.belongs_to_or_shared_by(current_user.id, current_user.group_ids).map(&:id) + # samples = Sample.by_exact_name(search_string).joins(:collections_samples).where('collections_samples.collection_id in (?)', collection_ids).uniq + # samples.select { |s| ElementPolicy.new(current_user, s).update? } - ids = samples.pluck(:id) - rs = ReactionsSample.where(sample_id: ids) - res = samples.map do |s| - { id: s.id, name: s.name, short_label: s.short_label, type: rs.find { |r| r.sample_id == s.id }&.type&.sub(/^Reactions/, '')&.sub(/Sample/, '') } - end - res + # ids = samples.pluck(:id) + # rs = ReactionsSample.where(sample_id: ids) + # res = samples.map do |s| + # { id: s.id, name: s.name, short_label: s.short_label, type: rs.find { |r| r.sample_id == s.id }&.type&.sub(/^Reactions/, '')&.sub(/Sample/, '') } + # end + # res end desc 'assign attachment to sample' @@ -35,29 +35,29 @@ class InboxAPI < Grape::API error!('402 Unauthorized', 402) unless attachment.created_for == current_user.id end post ':sample_id' do - analyses_container = Sample.find(params[:sample_id]).container.children.find_by(container_type: "analyses") - attachment = Attachment.find(params[:attachment_id]) - analysis_name = attachment.filename.chomp(File.extname(attachment.filename)) + # analyses_container = Sample.find(params[:sample_id]).container.children.find_by(container_type: "analyses") + # attachment = Attachment.find(params[:attachment_id]) + # analysis_name = attachment.filename.chomp(File.extname(attachment.filename)) - new_analysis_container = analyses_container.children.create(container_type: 'analysis', name: analysis_name) - dataset = new_analysis_container.children.create(parent_id: new_analysis_container.id, container_type: 'dataset', name: analysis_name) - attachment.update_attributes!(attachable: dataset) + # new_analysis_container = analyses_container.children.create(container_type: 'analysis', name: analysis_name) + # dataset = new_analysis_container.children.create(parent_id: new_analysis_container.id, container_type: 'dataset', name: analysis_name) + # attachment.update_attributes!(attachable: dataset) - @link = if Rails.env.production? - "https://#{ENV['HOST'] || ENV['SMTP_DOMAIN']}/mydb/collection/all/sample/#{@sample.id}" - else - "http://#{ENV['HOST'] || 'localhost:3000'}/mydb/collection/all/sample/#{@sample.id}" - end + # @link = if Rails.env.production? + # "https://#{ENV['HOST'] || ENV['SMTP_DOMAIN']}/mydb/collection/all/sample/#{@sample.id}" + # else + # "http://#{ENV['HOST'] || 'localhost:3000'}/mydb/collection/all/sample/#{@sample.id}" + # end - Message.create_msg_notification( - channel_subject: Channel::ASSIGN_INBOX_TO_SAMPLE, - message_from: current_user.id, - data_args: { filename: attachment.filename, info: "#{@sample.short_label} #{@sample.name}"}, - url: @link, - level: 'success' - ) + # Message.create_msg_notification( + # channel_subject: Channel::ASSIGN_INBOX_TO_SAMPLE, + # message_from: current_user.id, + # data_args: { filename: attachment.filename, info: "#{@sample.short_label} #{@sample.name}"}, + # url: @link, + # level: 'success' + # ) - dataset + # dataset end end end diff --git a/app/api/chemotion/literature_api.rb b/app/api/chemotion/literature_api.rb index 3d01d6934..97e3b0ee3 100644 --- a/app/api/chemotion/literature_api.rb +++ b/app/api/chemotion/literature_api.rb @@ -13,6 +13,8 @@ def citation_for_elements(id = params[:element_id], type = @element_klass, cat = resource :literatures do after_validation do + @is_owned = nil + @is_public = nil unless request.url =~ /doi\/metadata|ui_state|collection/ @element_klass = params[:element_type].classify @element = @element_klass.constantize.find_by(id: params[:element_id]) @@ -22,18 +24,49 @@ def citation_for_elements(id = params[:element_id], type = @element_klass, cat = else @element_policy.update? end + + @is_public = "Collections#{params[:element_type].classify}".constantize.where( + "#{params[:element_type]}_id = ? and collection_id in (?)", + params[:element_id], + [Collection.public_collection_id, Collection.scheme_only_reactions_collection.id] + ).presence error!('401 Unauthorized', 401) unless allowed + @cat = @is_public ? 'public' : 'detail' end end + desc "Update type of literals by element" + params do + requires :element_id, type: Integer + requires :element_type, type: String, values: %w[sample reaction research_plan] + requires :id, type: Integer + requires :litype, type: String, values: %w[citedOwn citedRef referTo] + end + put do + Literal.find(params[:id])&.update(litype: params[:litype]) + { literatures: citation_for_elements(params[:element_id], @element_klass, @cat) } + end + desc "Return the literature list for the given element" params do requires :element_id, type: Integer requires :element_type, type: String, values: %w[sample reaction research_plan] + optional :is_all, type: Boolean, default: false end get do - { literatures: citation_for_elements } + if (params[:is_all] && params[:is_all] == true && params[:element_type] == 'reaction') + literatures = citation_for_elements(params[:element_id], @element_klass, @cat) || [] + reaction = Reaction.find(params[:element_id]) + reaction.products.each do |p| + literatures = literatures + citation_for_elements(p.id, 'Sample', @cat) + end + { literatures: literatures } + else + { literatures: citation_for_elements(params[:element_id], @element_klass, @cat) } + end + # literatures = Literature.by_element_attributes_and_cat(params[:element_id], @element_klass, %w[detail public]) + # { literatures: literatures } end desc 'create a literature entry' @@ -73,14 +106,14 @@ def citation_for_elements(id = params[:element_id], type = @element_klass, cat = element_type: @element_klass, element_id: params[:element_id], litype: params[:ref][:litype], - category: 'detail' + category: @cat } unless Literal.find_by(attributes) Literal.create(attributes) @element.touch end - { literatures: citation_for_elements } + { literatures: citation_for_elements(params[:element_id], @element_klass, @cat) } end params do @@ -95,7 +128,7 @@ def citation_for_elements(id = params[:element_id], type = @element_klass, cat = # user_id: current_user.id, element_type: @element_klass, element_id: params[:element_id], - category: 'detail' + category: @cat )&.destroy! end @@ -108,9 +141,21 @@ def citation_for_elements(id = params[:element_id], type = @element_klass, cat = after_validation do set_var(params[:id], params[:is_sync_to_me]) error!(404) unless @c + if !@is_owned + obj = fetch_collection_w_current_user(params[:id], params[:is_sync_to_me]) + @is_public = obj['shared_by'] && obj['shared_by']['initials'] == 'CI' + end end get do + if @is_public + return { + collectionRefs: Literature.none, + sampleRefs: Literature.by_element_attributes_and_cat(sample_ids, 'Sample', 'public').group('literatures.id'), + reactionRefs: Literature.by_element_attributes_and_cat(reaction_ids, 'Reaction', 'public').group('literatures.id'), + researchPlanRefs: Literature.none, + } + end sample_ids = @dl_s > 1 ? @c.sample_ids : [] reaction_ids = @dl_r > 1 ? @c.reaction_ids : [] research_plan_ids = @dl_rp > 1 ? @c.research_plan_ids : [] @@ -152,10 +197,15 @@ def citation_for_elements(id = params[:element_id], type = @element_klass, cat = @sids = @dl_s > 1 ? @c.samples.by_ui_state(declared(params)[:sample]).pluck(:id) : [] @rids = @dl_r > 1 ? @c.reactions.by_ui_state(declared(params)[:reaction]).pluck(:id) : [] @cat = "detail" + if !@is_owned + obj = fetch_collection_w_current_user(params[:id], params[:is_sync_to_me]) + @is_public = obj['shared_by_id'] && obj['shared_by_id'] == User.chemotion_user.id + end end post do - if params[:ref] && @pl >= 1 + @cat = @is_public ? 'public' : 'detail' + if params[:ref] && (@pl >= 1 || @is_public) lit = if params[:ref][:is_new] Literature.find_or_create_by( doi: params[:ref][:doi], @@ -176,7 +226,7 @@ def citation_for_elements(id = params[:element_id], type = @element_klass, cat = element_type: type, element_id: id, litype: params[:ref][:litype], - category: 'detail' + category: @cat ) end end diff --git a/app/api/chemotion/message_api.rb b/app/api/chemotion/message_api.rb index d9aac4883..f5346629c 100644 --- a/app/api/chemotion/message_api.rb +++ b/app/api/chemotion/message_api.rb @@ -16,7 +16,7 @@ class MessageAPI < Grape::API requires :is_ack, type: Integer, desc: 'whether messages are acknowledged or not' end get 'list' do - messages = NotifyMessage.where(receiver_id: current_user.id) + messages = NotifyMessage.where(receiver_id: current_user.id).limit(100) messages = messages.where(is_ack: params[:is_ack]) if params[:is_ack] < 9 message_list = present(messages, with: Entities::MessageEntity, root: 'messages') diff --git a/app/api/chemotion/molecule_api.rb b/app/api/chemotion/molecule_api.rb index af2a8706b..c436f5df4 100644 --- a/app/api/chemotion/molecule_api.rb +++ b/app/api/chemotion/molecule_api.rb @@ -32,7 +32,6 @@ class MoleculeAPI < Grape::API post do smiles = params[:smiles] svg = params[:svg_file] - babel_info = OpenBabelService.molecule_info_from_structure(smiles, 'smi') inchikey = babel_info[:inchikey] return {} unless inchikey @@ -71,12 +70,15 @@ class MoleculeAPI < Grape::API svg_file_src = Rails.public_path.join('images', 'molecules', molecule.molecule_svg_file) if File.exist?(svg_file_src) mol = molecule.molfile.lines[0..1] - if mol[1]&.strip&.match?('OpenBabel') - svg = File.read(svg_file_src) - svg_process = SVG::Processor.new.structure_svg('openbabel', svg, svg_digest) + if svg.nil? || svg&.include?('Open Babel') + svg = Molecule.svg_reprocess(svg, molecule.molfile) + svg_process = SVG::Processor.new.structure_svg('ketcher', svg, svg_digest, true) else FileUtils.cp(svg_file_src, svg_process[:svg_file_path]) end + else + svg = Molecule.svg_reprocess(svg, molecule.molfile) + svg_process = SVG::Processor.new.structure_svg('ketcher', svg, svg_digest, true) end end molecule.attributes.merge(temp_svg: File.exist?(svg_process[:svg_file_path]) && svg_process[:svg_file_name], ob_log: babel_info[:ob_log]) diff --git a/app/api/chemotion/public_api.rb b/app/api/chemotion/public_api.rb index dfd658c61..8f4d52c00 100644 --- a/app/api/chemotion/public_api.rb +++ b/app/api/chemotion/public_api.rb @@ -1,31 +1,52 @@ +# frozen_string_literal: true + +require 'open-uri' + +# Belong to Chemotion module module Chemotion + # API for Public data class PublicAPI < Grape::API - helpers do - def send_notification(attachment, user, status, has_error = false) - data_args = { 'filename': attachment.filename, 'comment': 'the file has been updated' } - level = 'success' - if has_error - data_args['comment'] = ' an error has occurred, the file is not changed.' - level = 'error' - elsif status == 4 - data_args['comment'] = ' file has not changed.' - level = 'info' - elsif @status == 7 - data_args['comment'] = ' an error has occurred while force saving the document, please review your changes.' - level = 'error' - end - message = Message.create_msg_notification( - channel_subject: Channel::EDITOR_CALLBACK, message_from: user.id, - data_args: data_args, attach_id: attachment.id, research_plan_id: attachment.attachable_id, level: level - ) - end - end + include Grape::Kaminari + # helpers do + # def send_notification(attachment, user, status, has_error = false) + # data_args = { 'filename': attachment.filename, 'comment': 'the file has been updated' } + # level = 'success' + # if has_error + # data_args['comment'] = ' an error has occurred, the file is not changed.' + # level = 'error' + # elsif status == 4 + # data_args['comment'] = ' file has not changed.' + # level = 'info' + # elsif @status == 7 + # data_args['comment'] = ' an error has occurred while force saving the document, please review your changes.' + # level = 'error' + # end + # message = Message.create_msg_notification( + # channel_subject: Channel::EDITOR_CALLBACK, message_from: user.id, + # data_args: data_args, attach_id: attachment.id, research_plan_id: attachment.attachable_id, level: level + # ) + # end + # end + helpers CompoundHelpers + helpers PublicHelpers namespace :public do get 'ping' do status 204 end + namespace :generic_templates do + desc "get active generic templates" + params do + requires :klass, type: String, desc: 'Klass', values: %w[Element Segment Dataset] + end + get do + list = "#{params[:klass]}Klass".constantize.where(is_active: true).where.not(released_at: nil).select { |s| s["is_generic"].blank? } + entities = Entities::GenericPublicEntity.represent(list) + # entities.length > 1 ? de_encode_json(entities) : [] + end + end + namespace :element_klasses_name do desc "get klasses" params do @@ -41,7 +62,76 @@ def send_notification(attachment, user, status, has_error = false) namespace :omniauth_providers do desc "get omniauth providers" get do - Devise.omniauth_configs.keys + res = {} + config = Devise.omniauth_configs + config.each { |k, _v| res[k] = { icon: File.basename(config[k].options[:icon] || '') } } + res + end + end + + namespace :article_init do + get do + { is_article_editor: current_user&.is_article_editor || false } + end + end + + namespace :howto_init do + get do + { is_howto_editor: current_user&.is_howto_editor || false } + end + end + + namespace :find_adv_values do + helpers do + def query_authors(name) + result = User.where(type: %w(Person Group Collaborator)).where( + <<~SQL + users.id in ( + select distinct(pa.author_id)::integer from publication_authors pa + ) + SQL + ) + .by_name(params[:name]).limit(10) + .select( + <<~SQL + id as key, first_name, last_name, first_name || chr(32) || last_name as name, first_name || chr(32) || last_name || chr(32) || '(' || name_abbreviation || ')' as label + SQL + ) + end + def query_ontologies(name) + result = PublicationOntologies.where('LOWER(ontologies) ILIKE ? ',"%#{params[:name]}%").limit(3) + .select( + <<~SQL + term_id as key, label, label as name + SQL + ).distinct + end + def query_embargo(name) + Collection.all_embargos(current_user&.id).where("LOWER(label) ILIKE '#{ActiveRecord::Base.send(:sanitize_sql_like, params[:name])}%'").limit(10) + .select( + <<~SQL + id as key, label, label as name + SQL + ) + end + end + desc 'Find top 3 matched advanced values' + params do + requires :name, type: String, allow_blank: false, regexp: /^[\w]+([\w -]*)*$/ + requires :adv_type, type: String, allow_blank: false, desc: 'Type', values: %w[Authors Contributors Ontologies Embargo] + end + get do + result = case params[:adv_type] + when 'Authors', 'Contributors' + query_authors(params[:name]) + when 'Ontologies' + query_ontologies(params[:name]) + when 'Embargo' + query_embargo(params[:name]) + else + [] + end + { result: result } end end @@ -79,7 +169,7 @@ def send_notification(attachment, user, status, has_error = false) before do error!('401 Unauthorized', 401) if params[:key].nil? payload = JWT.decode(params[:key], Rails.application.secrets.secret_key_base) unless params[:key].nil? - error!('401 Unauthorized', 401) if payload&.length == 0 + error!('401 Unauthorized', 401) unless payload.present? @status = params[:status].is_a?(Integer) ? params[:status] : 0 if @status > 1 @@ -104,7 +194,6 @@ def send_notification(attachment, user, status, has_error = false) end post do - # begin case @status when 1 @@ -171,17 +260,12 @@ def send_notification(attachment, user, status, has_error = false) desc "Return all current organizations" get "organizations" do - Affiliation.pluck("DISTINCT organization") + Affiliation.where.not(organization: ENV['BLIST_ORGANIZATIONS']).pluck("DISTINCT organization") end desc "Return all current departments" get "departments" do - Affiliation.pluck("DISTINCT department") - end - - desc "Return all current groups" - get "groups" do - Affiliation.pluck("DISTINCT affiliations.group") + Affiliation.where.not(department: ENV['BLIST_DEPARTMENTS']).pluck("DISTINCT trim(department)") end desc "return organization's name from email domain" @@ -191,11 +275,594 @@ def send_notification(attachment, user, status, has_error = false) Affiliation.where(domain: params[:domain]).where.not(organization: nil).first&.organization end end + + get 'collection' do + pub_coll = Collection.public_collection + if current_user + coll = SyncCollectionsUser.find_by(user_id: current_user.id, collection_id: pub_coll.id) + { id: coll&.id, is_sync_to_me: true } + else + { id: nil } + end + end + + resource :pid do + params do + requires :id, type: Integer + end + desc "Query samples, reaction and datasets from publication id" + post do + pub = Publication.find(params[:id]) + return "/home" unless pub + + case pub.element_type + when 'Sample' + return "/molecules/#{pub.element.molecule_id}" if pub.state&.match(Regexp.union(%w[completed])) + return "/review/review_sample/#{pub.element_id}" if %w[pending reviewed accepted].include?(pub.state) && pub.ancestry.nil? + if %w[pending reviewed accepted].include?(pub.state) && !pub.ancestry.nil? + root = pub.root + return "/review/review_reaction/#{root.element_id}" if root && %w[pending reviewed accepted].include?(root.state) + end + when 'Reaction' + return "/reactions/#{pub.element_id}" if pub.state&.match(Regexp.union(%w[completed])) + return "/review/review_reaction/#{pub.element_id}" if %w[pending reviewed accepted].include?(pub.state) + when 'Container' + return "/datasets/#{pub.element_id}" if pub.state&.match(Regexp.union(%w[completed])) + if %w[pending reviewed accepted].include?(pub.state) && !pub.ancestry.nil? + root = pub.root + return "/review/review_#{root.element_type=='Reaction'? 'reaction' : 'sample'}/#{root.element_id}" if root && %w[pending reviewed accepted].include?(root.state) + end + else + return "/home" + end + end + end + + resource :inchikey do + params do + requires :inchikey, type: String + optional :type, type: String # value: [] + optional :version, type: String + end + desc "Query samples and datasets from inchikey and type" + post do + inchikey = params[:inchikey] + molecule = Molecule.find_by(inchikey: inchikey) + return "/home" unless molecule + + type = params[:type] + return "/molecules/#{molecule.id.to_s}" if type.empty? + + version = params[:version] ? params[:version] : "" + analyses = Collection.public_collection.samples + .where("samples.molecule_id = ?", molecule.id.to_s) + .map(&:analyses).flatten + + analyses_filtered = analyses.select { |a| + em = a.extended_metadata + check = em['kind'].to_s.gsub(/\s/, '') == type + check = check && (em['analysis_version'] || '1') == version unless version.empty? + check + } + analysis = analyses_filtered.first + return "/datasets/#{analysis.id.to_s}" + end + end + + resource :molecules do + desc 'Return PUBLIC serialized molecules' + params do + optional :page, type: Integer, desc: 'page' + optional :pages, type: Integer, desc: 'pages' + optional :per_page, type: Integer, desc: 'per page' + optional :adv_flag, type: Boolean, desc: 'advanced search?' + optional :adv_type, type: String, desc: 'advanced search type', values: %w[Authors Ontologies Embargo] + optional :adv_val, type: Array[String], desc: 'advanced search value', regexp: /^(\d+|([[:alpha:]]+:\d+))$/ + optional :req_xvial, type: Boolean, default: false, desc: 'xvial is required or not' + end + paginate per_page: 10, offset: 0, max_per_page: 100 + get '/' do + public_collection_id = Collection.public_collection_id + params[:adv_val] + adv_search = ' ' + req_xvial = params[:req_xvial] + if params[:adv_flag] == true && params[:adv_type].present? && params[:adv_val].present? + case params[:adv_type] + when 'Authors' + adv_search = <<~SQL + INNER JOIN publication_authors pub on pub.element_id = samples.id and pub.element_type = 'Sample' and pub.state = 'completed' + and author_id in ('#{params[:adv_val].join("','")}') + SQL + when 'Ontologies' + adv_search = <<~SQL + INNER JOIN publication_ontologies pub on pub.element_id = samples.id and pub.element_type = 'Sample' + and term_id in ('#{params[:adv_val].join("','")}') + SQL + when 'Embargo' + param_sql = ActiveRecord::Base.send(:sanitize_sql_array, [' css.collection_id in (?)', params[:adv_val].map(&:to_i).join(',')]) + adv_search = <<~SQL + INNER JOIN collections_samples css on css.sample_id = samples.id and css.deleted_at ISNULL + and #{param_sql} + SQL + end + end + sample_join = <<~SQL + INNER JOIN ( + SELECT molecule_id, published_at max_published_at, sample_svg_file, id as sid + FROM ( + SELECT samples.*, pub.published_at, rank() OVER (PARTITION BY molecule_id order by pub.published_at desc) as rownum + FROM samples, publications pub + WHERE pub.element_type='Sample' and pub.element_id=samples.id and pub.deleted_at ISNULL + and samples.id IN ( + SELECT samples.id FROM samples + INNER JOIN collections_samples cs on cs.collection_id = #{public_collection_id} and cs.sample_id = samples.id and cs.deleted_at ISNULL + #{adv_search} + #{join_xvial_sql(req_xvial)} + )) s where rownum = 1 + ) s on s.molecule_id = molecules.id + SQL + + embargo_sql = <<~SQL + molecules.*, sample_svg_file, sid, + (select count(*) from publication_ontologies po where po.element_type = 'Sample' and po.element_id = sid) as ana_cnt, + (select "collections".label from "collections" inner join collections_samples cs on collections.id = cs.collection_id + and cs.sample_id = sid where "collections"."deleted_at" is null and (ancestry in ( + select c.id::text from collections c where c.label = 'Published Elements')) order by position asc limit 1) as embargo, + (select id from publications where element_type = 'Sample' and element_id = sid and deleted_at is null) as pub_id, + (select to_char(published_at, 'YYYY-MM-DD') from publications where element_type = 'Sample' and element_id = sid and deleted_at is null) as published_at, + (select taggable_data -> 'creators'->0->>'name' from publications where element_type = 'Sample' and element_id = sid and deleted_at is null) as author_name + SQL + + list = paginate(Molecule.joins(sample_join).order("s.max_published_at desc").select(embargo_sql)) + + entities = Entities::MoleculePublicationListEntity.represent(list, serializable: true) + sids = entities.map { |e| e[:sid] } + + com_config = Rails.configuration.compound_opendata + xvial_count_sql = <<~SQL + inner join element_tags e on e.taggable_type = 'Sample' and e.taggable_id = samples.id and (e.taggable_data -> 'xvial' is not null and e.taggable_data -> 'xvial' ->> 'num' != '') + SQL + x_cnt_ids = req_xvial ? sids.uniq : (Sample.joins(xvial_count_sql).where(id: sids).distinct.pluck(:id) || []) + xvial_com_sql = get_xvial_sql(req_xvial) + x_com_ids = Sample.joins(xvial_com_sql).where(id: sids).distinct.pluck(:id) if com_config.present? && com_config.allowed_uids.include?(current_user&.id) + + entities = entities.each do |obj| + obj[:xvial_count] = 1 if x_cnt_ids.include?(obj[:sid]) + obj[:xvial_com] = 1 if com_config.present? && com_config.allowed_uids.include?(current_user&.id) && (x_com_ids || []).include?(obj[:sid]) + obj[:xvial_archive] = get_xdata(obj[:inchikey], obj[:sid], req_xvial) + end + entities + end + end + + resource :reactions do + desc 'Return PUBLIC serialized reactions' + params do + optional :page, type: Integer, desc: 'page' + optional :pages, type: Integer, desc: 'pages' + optional :per_page, type: Integer, desc: 'per page' + optional :adv_flag, type: Boolean, desc: 'is it advanced search?' + optional :adv_type, type: String, desc: 'advanced search type', values: %w[Authors Ontologies Embargo] + optional :adv_val, type: Array[String], desc: 'advanced search value', regexp: /^(\d+|([[:alpha:]]+:\d+))$/ + optional :scheme_only, type: Boolean, desc: 'is it a scheme-only reaction?', default: false + end + paginate per_page: 10, offset: 0, max_per_page: 100 + get '/' do + if params[:adv_flag] === true && params[:adv_type].present? && params[:adv_val].present? + case params[:adv_type] + when 'Authors' + adv_search = <<~SQL + INNER JOIN publication_authors pub on pub.element_id = reactions.id and pub.element_type = 'Reaction' and pub.state = 'completed' + and author_id in ('#{params[:adv_val].join("','")}') + SQL + when 'Ontologies' + adv_search = <<~SQL + INNER JOIN publication_ontologies pub on pub.element_id = reactions.id and pub.element_type = 'Reaction' + and term_id in ('#{params[:adv_val].join("','")}') + SQL + when 'Embargo' + param_sql = ActiveRecord::Base.send(:sanitize_sql_array, [' cr.collection_id in (?)', params[:adv_val].map(&:to_i).join(',')]) + adv_search = <<~SQL + INNER JOIN collections_reactions cr on cr.reaction_id = reactions.id and cr.deleted_at is null + and #{param_sql} + SQL + else + adv_search = ' ' + end + else + adv_search = ' ' + end + com_config = Rails.configuration.compound_opendata + embargo_sql = <<~SQL + reactions.id, reactions.name, reactions.reaction_svg_file, publications.id as pub_id, to_char(publications.published_at, 'YYYY-MM-DD') as published_at, publications.taggable_data, + (select count(*) from publication_ontologies po where po.element_type = 'Reaction' and po.element_id = reactions.id) as ana_cnt, + (select "collections".label from "collections" inner join collections_reactions cr on collections.id = cr.collection_id and cr.deleted_at is null + and cr.reaction_id = reactions.id where "collections"."deleted_at" is null and (ancestry in ( + select c.id::text from collections c where c.label = 'Published Elements')) order by position asc limit 1) as embargo, + (select taggable_data -> 'new_version' -> 'id' from element_tags where taggable_type = 'Reaction' and taggable_id = reactions.id) as new_version + SQL + + if params[:scheme_only] + list = paginate(Collection.scheme_only_reactions_collection.reactions.joins(adv_search).joins(:publication).select(embargo_sql).order('publications.published_at desc')) + else + list = paginate(Collection.public_collection.reactions.joins(adv_search).joins(:publication).select(embargo_sql).order('publications.published_at desc')) + end + + entities = Entities::ReactionPublicationListEntity.represent(list, serializable: true) + + ids = entities.map { |e| e[:id] } + + xvial_count_sql = <<~SQL + inner join element_tags e on e.taggable_id = reactions_samples.sample_id and (e.taggable_data -> 'xvial' is not null and e.taggable_data -> 'xvial' ->> 'num' != '') + SQL + x_cnt_ids = ReactionsSample.joins(xvial_count_sql).where(type: 'ReactionsProductSample', reaction_id: ids).distinct.pluck(:reaction_id) || [] + + xvial_com_sql = <<~SQL + inner join samples s on reactions_samples.sample_id = s.id and s.deleted_at is null + inner join molecules m on m.id = s.molecule_id + inner join com_xvial(true) a on a.x_inchikey = m.inchikey + SQL + x_com_ids = ReactionsSample.joins(xvial_com_sql).where(type: 'ReactionsProductSample', reaction_id: ids).distinct.pluck(:reaction_id) if com_config.present? && com_config.allowed_uids.include?(current_user&.id) + + entities = entities.each do |obj| + obj[:xvial_count] = 1 if x_cnt_ids.include?(obj[:id]) + obj[:xvial_com] = 1 if com_config.present? && com_config.allowed_uids.include?(current_user&.id) && (x_com_ids || []).include?(obj[:id]) + end + + entities + end + end + + resource :publicElement do + desc "Return PUBLIC serialized elements (Reaction, sample)" + paginate per_page: 10, offset: 0, max_per_page: 100 + get '/', each_serializer: MoleculeGuestListSerializer do + public_collection_id = Collection.public_collection_id + sample_join = <<~SQL + INNER JOIN ( + SELECT molecule_id, max(pub.published_at) max_updated_at + FROM samples + INNER JOIN collections_samples cs on cs.collection_id = #{public_collection_id} and cs.sample_id = samples.id and cs.deleted_at ISNULL + INNER JOIN publications pub on pub.element_type='Sample' and pub.element_id=samples.id and pub.deleted_at ISNULL + GROUP BY samples.molecule_id + ) s on s.molecule_id = molecules.id + SQL + paginate(Molecule.joins(sample_join).order("s.max_updated_at desc")) + end + end + + resource :last_published do + desc "Return Last PUBLIC serialized entities" + get do + res = { + last_published: {} + } + + s_pub = Publication.where(element_type: 'Sample', state: 'completed').order(:published_at).last + unless s_pub.nil? + sample = s_pub.element + res["sample"] = { + id: sample.id, + sample_svg_file: sample.sample_svg_file, + molecule: sample.molecule, + tag: s_pub.taggable_data, + contributor: User.find(s_pub.published_by).name + } + end + + r_pub = Publication.where(element_type: 'Reaction', state: 'completed').order(:published_at).last + unless r_pub.nil? + reaction = r_pub.element + res["reaction"] = { + id: reaction.id, + reaction_svg_file: reaction.reaction_svg_file, + tag: r_pub.taggable_data, + contributor: User.find(r_pub.published_by).name + } + end + + res + end + end + + resource :last_published_sample do + desc "Return PUBLIC serialized molecules" + get do + sample = Collection.public_collection.samples.includes(:molecule, :residues). + where("samples.id not in (select reactions_samples.sample_id from reactions_samples where type != 'ReactionsProductSample')").order(:created_at).last + #TODO have and use a dedicated serializer for public sample + sample + end + end + + resource :dataset do + desc "Return PUBLISHED serialized dataset" + params do + requires :id, type: Integer, desc: "Dataset id" + end + get do + dataset = Container.find(params[:id]) + sample = dataset.root.containable + cids = sample.collections.pluck :id + if cids.include?(Collection.public_collection_id) + molecule = sample.molecule if sample.class.name == 'Sample' + + ds_json = ContainerSerializer.new(dataset).serializable_hash.deep_symbolize_keys + ds_json[:dataset_doi] = dataset.full_doi + ds_json[:pub_id] = dataset.publication&.id + + res = { + dataset: ds_json, + sample_svg_file: sample.class.name == 'Sample' ? sample.sample_svg_file : sample.reaction_svg_file, + molecule: { + sum_formular: molecule&.sum_formular, + molecular_weight: molecule&.molecular_weight, + cano_smiles: molecule&.cano_smiles, + inchistring: molecule&.inchistring, + inchikey: molecule&.inchikey, + molecule_svg_file: molecule&.molecule_svg_file, + pubchem_cid: molecule&.tag&.taggable_data && molecule&.tag&.taggable_data["pubchem_cid"] + }, + license: dataset.tag.taggable_data["publication"]["license"] || 'CC BY-SA', + publication: { + author_ids: sample&.publication&.taggable_data['author_ids'] || [], + creators: sample&.publication&.taggable_data['creators'] || [], + affiliation_ids: sample&.publication&.taggable_data['affiliation_ids'] || [], + affiliations: sample&.publication&.taggable_data['affiliations'] || {}, + published_at: sample&.publication&.taggable_data['published_at'], + } + } + else + res = nil + end + + return res + end + end + + resource :embargo do + helpers RepositoryHelpers + desc "Return PUBLISHED serialized collection" + params do + requires :id, type: Integer, desc: "collection id" + end + get do + pub = Publication.find_by(element_type: 'Collection', element_id: params[:id]) + { col: pub } + end + end + + + resource :col_list do + helpers RepositoryHelpers + after_validation do + @embargo_collection = Collection.find(params[:collection_id]) + @pub = @embargo_collection.publication + error!('401 Unauthorized', 401) if @pub.nil? + + if @pub.state != 'completed' + error!('401 Unauthorized', 401) unless current_user.present? && (User.reviewer_ids.include?(current_user.id) || @pub.published_by == current_user.id || current_user.type == 'Anonymous') + end + end + get do + anasql = <<~SQL + publications.*, (select count(*) from publication_ontologies po where po.element_type = publications.element_type and po.element_id = publications.element_id) as ana_cnt + SQL + sample_list = Publication.where(ancestry: nil, element: @embargo_collection.samples).select(anasql).order(updated_at: :desc) + reaction_list = Publication.where(ancestry: nil, element: @embargo_collection.reactions).select(anasql).order(updated_at: :desc) + list = sample_list + reaction_list + elements = [] + list.each do |e| + element_type = e.element&.class&.name + u = User.find(e.published_by) unless e.published_by.nil? + svg_file = e.element.sample_svg_file if element_type == 'Sample' + title = e.element.short_label if element_type == 'Sample' + + svg_file = e.element.reaction_svg_file if element_type == 'Reaction' + title = e.element.short_label if element_type == 'Reaction' + + scheme_only = element_type == 'Reaction' && e.taggable_data && e.taggable_data['scheme_only'] + elements.push( + id: e.element_id, pub_id: e.id, svg: svg_file, type: element_type, title: title, published_at: e.published_at&.strftime('%d-%m-%Y'), + published_by: u&.name, submit_at: e.created_at, state: e.state, scheme_only: scheme_only, ana_cnt: e.ana_cnt + ) + end + { elements: elements, embargo: @pub, embargo_id: params[:collection_id], current_user: { id: current_user&.id, type: current_user&.type } } + end + end + + resource :col_element do + helpers RepositoryHelpers + params do + requires :collection_id, type: Integer, desc: "collection id" + requires :el_id, type: Integer, desc: "element id" + end + after_validation do + @embargo_collection = Collection.find(params[:collection_id]) + pub = @embargo_collection.publication + error!('401 Unauthorized', 401) if pub.nil? + + if pub.state != 'completed' + error!('401 Unauthorized', 401) unless current_user.present? && (User.reviewer_ids.include?(current_user.id) || pub.published_by == current_user.id) + end + + end + get do + if params[:el_type] == 'Reaction' + return get_pub_reaction(params[:el_id]) + elsif params[:el_type] == 'Sample' + sample = Sample.find(params[:el_id]) + return get_pub_molecule(sample.molecule_id) + end + end + end + + resource :reaction do + helpers RepositoryHelpers + desc "Return PUBLISHED serialized reaction" + params do + requires :id, type: Integer, desc: "Reaction id" + end + get do + r = CollectionsReaction.where(reaction_id: params[:id], collection_id: [Collection.public_collection_id, Collection.scheme_only_reactions_collection.id]) + return nil unless r.present? + + return get_pub_reaction(params[:id]) + end + end + + resource :molecule do + helpers RepositoryHelpers + desc 'Return serialized molecule with list of PUBLISHED dataset' + params do + requires :id, type: Integer, desc: 'Molecule id' + optional :adv_flag, type: Boolean, desc: 'advanced search flag' + optional :adv_type, type: String, desc: 'advanced search type', allow_blank: true, values: %w[Authors Ontologies Embargo] + optional :adv_val, type: Array[String], desc: 'advanced search value', regexp: /^(\d+|([[:alpha:]]+:\d+))$/ + end + get do + get_pub_molecule(params[:id], params[:adv_flag], params[:adv_type], params[:adv_val]) + end + end + + resource :download do + desc 'download publication file' + params do + requires :id, type: Integer, desc: 'Id' + end + resource :attachment do + desc 'download publication attachment' + after_validation do + @attachment = Attachment.find_by(id: params[:id]) + error!('404 Attachment not found', 404) unless @attachment + @publication = @attachment&.container&.parent&.publication + error!('404 Is not published yet', 404) unless @publication&.state&.include?('completed') + end + get do + content_type 'application/octet-stream' + header['Content-Disposition'] = "attachment; filename=#{@attachment.filename}" + env['api.format'] = :binary + @attachment.read_file + end + end + resource :dataset do + desc 'download publication dataset as zip' + after_validation do + @container = Container.find_by(id: params[:id]) + error!('404 Dataset not found', 404) unless @container + @publication = @container&.parent&.publication + error!('404 Is not published yet', 404) unless @publication&.state&.include?('completed') + end + get do + content_type 'application/zip, application/octet-stream' + filename = URI.escape("#{@container.parent&.name.gsub(/\s+/, '_')}-#{@container.name.gsub(/\s+/, '_')}.zip") + header['Content-Disposition'] = "attachment; filename=#{filename}" + env['api.format'] = :binary + zip_f = Zip::OutputStream.write_buffer do |zip| + @container.attachments.each do |att| + zip.put_next_entry att.filename + zip.write att.read_file + end + zip.put_next_entry 'dataset_description.txt' + zip.write <<~DESC + dataset name: #{@container.name} + instrument: #{@container.extended_metadata.fetch('instrument', nil)} + description: + #{@container.description} + + Files: + DESC + @container.attachments.each do |att| + zip.write "#{att.filename} #{att.checksum}\n" + end + end + zip_f.rewind + zip_f.read + end + end + end + + resource :metadata do + desc "batch download metadata" + params do + requires :type, type: String, desc: 'Type', values: %w[Sample Reaction Container Collection] + requires :offset, type: Integer, desc: 'Offset', default: 0 + requires :limit, type: Integer, desc: 'Limit', default: 100 + optional :date_from, type: String, desc: 'Published date from' + optional :date_to, type: String, desc: 'Published date to' + end + get :publications do + service_url = Rails.env.production? ? 'https://www.chemotion-repository.net' : 'http://localhost:3000' + api_url = '/api/v1/public/metadata/download_json?inchikey=' + + result = declared(params, include_missing: false) + list = [] + limit = params[:limit] - params[:offset] > 1000 ? params[:offset] + 1000 : params[:limit] + scope = Publication.where(element_type: params[:type]) + scope = scope.where('published_at >= ?', params[:date_from]) if params[:date_from].present? + scope = scope.where('published_at <= ?', params[:date_to]) if params[:date_to].present? + publications = scope.order(:published_at).offset(params[:offset]).limit(limit) + publications.map do |publication| + inchikey = publication&.doi&.suffix + list.push("#{service_url}#{api_url}#{inchikey}") if inchikey.present? + end + result[:publications] = list + result[:limit] = limit + result + end + + desc "metadata of publication" + params do + optional :id, type: Integer, desc: "Id" + optional :type, type: String, desc: "Type", values: %w[sample reaction container collection] + optional :inchikey, type: String, desc: "inchikey" + end + after_validation do + @type = params['type']&.classify + @publication = Publication.find_by(element_type: @type, element_id: params['id'], state: 'completed') if params['id'].present? + if params['inchikey'].present? && @publication.nil? + doi = Doi.find_by(suffix: params['inchikey']) + @publication = Publication.find_by(doi_id: doi.id, state: 'completed') if doi.present? + @type = @publication&.element_type + end + @type = @type == "Container" ? "Analysis" : @type + error!('404 Publication not found', 404) unless @publication.present? + end + desc "Download metadata_xml" + get :download do + filename = URI.escape("metadata_#{@type}_#{@publication.element_id}-#{Time.new.strftime("%Y%m%d%H%M%S")}.xml") + content_type('application/octet-stream') + header['Content-Disposition'] = "attachment; filename=" + filename + env['api.format'] = :binary + @publication.metadata_xml + end + + desc "Download JSON-Link Data" + get :download_json do + filename = URI.escape("JSON-LD_#{@type}_#{@publication.element_id}-#{Time.new.strftime("%Y%m%d%H%M%S")}.json") + content_type('application/json') + header['Content-Disposition'] = "attachment; filename=" + filename + env['api.format'] = :binary + @publication.json_ld + end + + desc "Get JSON-Link Data" + get :jsonld do + @publication.json_ld + end + end + + resource :published_statics do + desc 'Return PUBLIC statics' + get do + ActiveRecord::Base.connection.exec_query('select * from publication_statics as ps') + end + end end namespace :upload do before do - error!('Unauthorized' , 401) unless TokenAuthentication.new(request, with_remote_addr: true).is_successful? + error!('Unauthorized', 401) unless TokenAuthentication.new(request, with_remote_addr: true).is_successful? end resource :attachments do desc "Upload files" @@ -213,7 +880,7 @@ def send_notification(attachment, user, status, has_error = false) key = AuthenticationKey.find_by(token: token) - helper = CollectorHelper.new(key.user.email , recipient_email) + helper = CollectorHelper.new(key.user.email, recipient_email) if helper.sender_recipient_known? dataset = helper.prepare_new_dataset(subject) diff --git a/app/api/chemotion/public_chemscanner_api.rb b/app/api/chemotion/public_chemscanner_api.rb new file mode 100644 index 000000000..f592de666 --- /dev/null +++ b/app/api/chemotion/public_chemscanner_api.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +# Belong to Chemotion module +module Chemotion + require 'open3' + require 'ole/storage' + require 'json' + + # API for ChemScanner manipulation + # rubocop:disable Metric/ClassLength + class PublicChemscannerAPI < Grape::API + helpers ChemscannerHelpers + format :json + + before do + WardenAuthentication.new(env).current_user + end + + # rubocop:disable BlockLength + resource :public_chemscanner do + desc 'Upload import files' + + post 'upload' do + get_mol = params.delete('get_mol') == 'true' + + schemes = [] + files = [] + reactions = [] + molecules = [] + + params.each do |(uid, file)| + begin + temp_file = file['tempfile'] + + source = Chemscanner::Source.create_from_uploaded_file(file, uid, current_user) + file_schemes = source.scan + + source.save if current_user + + files.concat([source] + source.children) + next if file_schemes.empty? + + file_reactions = file_schemes.map(&:reactions).flatten + file_molecules = file_schemes.map(&:molecules).flatten + + schemes.concat(file_schemes) + reactions.concat(file_reactions) + molecules.concat(file_molecules) + rescue StandardError => e + Rails.logger.error("Error while scanning: #{e}") + return { + display: display, + files: [], + schemes: [], + molecules: [], + reactions: [] + } + ensure + temp_file.close + temp_file.unlink + end + end + + serialize_outputs( + files, + schemes, + reactions, + molecules, + get_mol + ) + end + + resource :svg do + desc 'Convert svg from MDL and SMILES' + params do + requires :molecules, type: Array, desc: 'Array of molecules that need SVG' + requires :reactions, type: Array, desc: 'Array of reactions that need SVG' + end + + post 'mdl' do + molecules = params[:molecules].map { |m| + { + mid: m[:mid], + svg: Chemotion::OpenBabelService.mdl_to_trans_svg(m[:mdl]) + } + } + + reactions = params[:reactions].map { |r| + info = { + reactants_mdl: (r[:reactants] || []).map { |m| m[:mdl] }, + reagents_mdl: (r[:reagents] || []).map { |m| m[:mdl] }, + products_mdl: (r[:products] || []).map { |m| m[:mdl] }, + reagents_smiles: r[:reagents_smiles].uniq || [] + } + r[:svg] = SVG::ChemscannerComposer.reaction_svg_from_mdl( + info, + ChemScanner.solvents.values + ) + + r + } + + { molecules: molecules, reactions: reactions } + end + end + + resource :abbreviations do + post 'all' do + { + abbreviations: ChemScanner.all_abbreviations, + superatoms: ChemScanner.all_superatoms + } + end + end + + resource :export do + params do + requires :reactions, type: Array, desc: 'Array of reactions' + requires :molecules, type: Array, desc: 'Array of molecules' + end + + post 'cml' do + molecules = params['molecules'].map { |m| OpenStruct.new(m) } + reactions = params['reactions'].map { |reaction| + OpenStruct.new( + id: reaction['id'], + reactants: reaction['reactants'].map { |r| OpenStruct.new(r) }, + products: reaction['products'].map { |r| OpenStruct.new(r) }, + reagents: reaction['reagents'].map { |r| OpenStruct.new(r) }, + reagent_smiles: [], + yield: reaction['yield'].to_s, + time: reaction['time'].to_s, + temperature: reaction['temperature'].to_s, + description: reaction['description'] + ) + } + + { + molecules: ChemScanner::Export::CML.new(molecules, true).process, + reactions: ChemScanner::Export::CML.new(reactions, false).process + } + end + end + + resource :ui do + post 'version' do + cur_version = Chemscanner::Process::CHEMSCANNER_VERSION + current_user && list_version = Chemscanner::Scheme.pluck(:version) + .push(cur_version) + .uniq + + { + version: cur_version, + list_version: list_version + } + end + end + end + # rubocop:enable BlockLength + end + # rubocop:enable Metric/ClassLength +end diff --git a/app/api/chemotion/reaction_api.rb b/app/api/chemotion/reaction_api.rb index 39a9f1ef5..428c6b10b 100644 --- a/app/api/chemotion/reaction_api.rb +++ b/app/api/chemotion/reaction_api.rb @@ -110,6 +110,7 @@ def update_materials_for_reaction(reaction, material_attributes, current_user) container_info = attributes[:container] attributes.delete(:container) attributes.delete(:segments) + attributes.delete(:is_repo_public) new_sample = Sample.new( attributes ) @@ -165,7 +166,7 @@ def update_materials_for_reaction(reaction, material_attributes, current_user) included_sample_ids << existing_sample.id - existing_association = ReactionsSample.find_by(sample_id: sample.id) + existing_association = ReactionsSample.find_by(reaction_id: reaction.id, sample_id: sample.id) # update existing associations if existing_association @@ -241,7 +242,7 @@ class ReactionAPI < Grape::API end post do - Import::FromChemScanner.from_list( + Import::FromChemscanner.from_list( params[:reactions], params[:molecules], current_user.id, @@ -308,7 +309,7 @@ class ReactionAPI < Grape::API get do reaction = Reaction.find(params[:id]) - { reaction: ElementPermissionProxy.new(current_user, reaction, user_ids, @element_policy).serialized, literatures: citation_for_elements(params[:id], 'Reaction') } + { reaction: ElementPermissionProxy.new(current_user, reaction, user_ids, @element_policy).serialized, literatures: citation_for_elements(params[:id], 'Reaction'), publication: Publication.find_by(element: reaction) || {} } end end diff --git a/app/api/chemotion/report_api.rb b/app/api/chemotion/report_api.rb index 5259fa73f..ef709f0bc 100644 --- a/app/api/chemotion/report_api.rb +++ b/app/api/chemotion/report_api.rb @@ -41,6 +41,22 @@ def is_int? docx end + desc "get DOI list" + params do + requires :elements + end + post :dois do + elements = params[:elements] + pub_list = [] + elements.each do |element| + publication = Publication.find_by(element_id: element[:id], element_type: element[:type].capitalize) + pub_list.push(publication) unless publication.nil? + # publications = [publication] + publication.descendants + end + entities = Entities::PublicationEntity.represent(pub_list, serializable: true) + {dois: entities || []} + end + params do use :export_params end @@ -220,6 +236,7 @@ def is_int? optional :fileDescription end post :reports, each_serializer: ReportSerializer do +# byebug spl_settings = hashize(params[:splSettings]) rxn_settings = hashize(params[:rxnSettings]) si_rxn_settings = hashize(params[:siRxnSettings]) diff --git a/app/api/chemotion/repository_api.rb b/app/api/chemotion/repository_api.rb new file mode 100644 index 000000000..5f349ff0d --- /dev/null +++ b/app/api/chemotion/repository_api.rb @@ -0,0 +1,1774 @@ +# frozen_string_literal: true + +require 'securerandom' +module Chemotion + # Repository API + class RepositoryAPI < Grape::API + include Grape::Kaminari + helpers ContainerHelpers + helpers ParamsHelpers + helpers CollectionHelpers + helpers SampleHelpers + helpers SubmissionHelpers + helpers EmbargoHelpers + + namespace :repository do + helpers do + def duplicate_analyses(new_element, analyses_arr, ik = nil) + unless new_element.container + Container.create_root_container(containable: new_element) + new_element.reload + end + analyses = Container.analyses_container(new_element.container.id).first + parent_publication = new_element.publication + analyses_arr&.each do |ana| + new_ana = analyses.children.create( + name: ana.name, + container_type: ana.container_type, + description: ana.description + ) + new_ana.extended_metadata = ana.extended_metadata + new_ana.save! + + # move reserved doi + if (d = ana.doi) + d.update(doiable: new_ana) + else + d = Doi.create_for_analysis!(new_ana, ik) + end + Publication.create!( + state: Publication::STATE_PENDING, + element: new_ana, + original_element: ana, + published_by: current_user.id, + doi: d, + parent: new_element.publication, + taggable_data: @publication_tag.merge( + author_ids: @author_ids + ) + ) + # duplicate datasets and copy attachments + ana.children.where(container_type: 'dataset').each do |ds| + new_dataset = new_ana.children.create(container_type: 'dataset') + ds.attachments.each do |att| + copied_att = att.copy(attachable_type: 'Container', attachable_id: new_dataset.id, transferred: true) + copied_att.save! + new_dataset.attachments << copied_att + + # copy publication image file to public/images/publications/{attachment.id}/{attachment.filename} + if MimeMagic.by_path(copied_att.filename)&.type&.start_with?('image') + file_path = File.join('public/images/publications/', copied_att.id.to_s, '/', copied_att.filename) + public_path = File.join('public/images/publications/', copied_att.id.to_s) + FileUtils.mkdir_p(public_path) + File.write(file_path, copied_att.store.read_file.force_encoding('utf-8')) if copied_att.store.file_exist? + end + end + + new_dataset.name = ds.name + new_dataset.extended_metadata = ds.extended_metadata + new_dataset.save! + end + end + end + + def link_analyses(new_element, analyses_arr) + unless new_element.container + Container.create_root_container(containable: new_element) + new_element.reload + end + analyses = Container.analyses_container(new_element.container.id).first + + analyses_arr&.each do |analysis| + analysis_link = analyses.children.create( + name: analysis.name, + container_type: 'link', + description: analysis.description + ) + + if analysis.container_type == 'link' + analysis_link.extended_metadata = analysis.extended_metadata + else + analysis_link.extended_metadata = { + target_id: analysis.id, + target_type: analysis.container_type, + } + end + analysis_link.save! + end + end + + def reviewer_collections + c = current_user.pending_collection + User.reviewer_ids.each do |rev_id| + SyncCollectionsUser.find_or_create_by( + collection_id: c.id, + user_id: rev_id, + shared_by_id: c.user_id, + permission_level: 3, + sample_detail_level: 10, + reaction_detail_level: 10, + label: 'REVIEWING' + ) + end + end + + # Create(clone) publication sample/analyses with dois + def duplicate_sample(sample = @sample, analyses = @analyses, parent_publication_id = nil) + new_sample = sample.dup + new_sample.collections << current_user.pending_collection + new_sample.collections << Collection.element_to_review_collection + new_sample.collections << @embargo_collection unless @embargo_collection.nil? + new_sample.save! + new_sample.copy_segments(segments: sample.segments, current_user_id: current_user.id) if sample.segments + unless @literals.nil? + lits = @literals&.select { |lit| lit['element_type'] == 'Sample' && lit['element_id'] == sample.id } + duplicate_literals(new_sample, lits) + end + duplicate_analyses(new_sample, analyses, new_sample.molecule.inchikey) + has_analysis = new_sample.analyses.present? + if (has_analysis = new_sample.analyses.present?) + if (d = sample.doi) + d.update!(doiable: new_sample) + else + d = Doi.create_for_element!(new_sample) + end + pub = Publication.create!( + state: Publication::STATE_PENDING, + element: new_sample, + original_element: sample, + published_by: current_user.id, + doi: d, + parent_id: parent_publication_id, + taggable_data: @publication_tag.merge( + author_ids: @author_ids, + original_analysis_ids: analyses.pluck(:id), + analysis_ids: new_sample.analyses.pluck(:id) + ) + ) + end + new_sample.analyses.each do |ana| + Publication.find_by(element: ana).update(parent: pub) + end + new_sample.update_tag!(analyses_tag: true) + new_sample + end + + def create_new_sample_version(sample = @sample, reaction = @reaction) + new_sample = sample.dup + new_sample.previous_version = sample + new_sample.collections << current_user.versions_collection + new_sample.save! + new_sample.copy_segments(segments: sample.segments, current_user_id: current_user.id) if sample.segments + + duplicate_literals(new_sample, sample.literals) + + analyses = sample.analyses ? sample.analyses.or(sample.links) : sample.links + link_analyses(new_sample, analyses) + + new_sample.update_tag!(analyses_tag: true) + + sample.tag_as_previous_version(new_sample) + new_sample.tag_as_new_version(sample) + new_sample.update_versions_tag + + unless reaction.nil? + new_sample.update_tag!(reaction_tag: reaction.id) + + if current_user.versions_collection.reactions.find_by(id: reaction.id).nil? + # this sample will replace the old sample when beeing published + new_sample.tag_replace_in_publication + else + # replace previous sample in reaction now + reaction_sample = reaction.reactions_samples.find_by(sample_id: sample.id) + reaction_sample.sample_id = new_sample.id + reaction_sample.save! + end + end + + new_sample + end + + def submit_new_sample_version(sample = @sample, parent_publication_id = nil) + sample.collections.clear + sample.collections << current_user.pending_collection + sample.collections << Collection.element_to_review_collection + sample.collections << @embargo_collection unless @embargo_collection.nil? + sample.save! + + if sample.analyses.or(sample.links).present? + # create dois and publications for analyses + sample.analyses.each do |analysis| + # create doi for analysis + if (doi = analysis.doi) + doi.update(doiable: analysis) + else + doi = Doi.create_for_analysis!(analysis, sample.molecule.inchikey) + end + + # create publication for analyses + Publication.create!( + state: Publication::STATE_PENDING, + element: analysis, + published_by: current_user.id, + doi: doi, + taggable_data: @publication_tag.merge( + author_ids: @author_ids + ) + ) + end + + # create doi for sample + if (doi = sample.doi) + doi.update!(doiable: sample) + else + doi = Doi.create_for_element!(sample) + end + + # create publication for sample + publication = Publication.create!( + state: Publication::STATE_PENDING, + element: sample, + published_by: current_user.id, + doi: doi, + parent_id: parent_publication_id, + taggable_data: @publication_tag.merge( + author_ids: @author_ids, + analysis_ids: sample.analyses.pluck(:id) + ) + ) + end + sample.analyses.each do |analysis| + Publication.find_by(element: analysis).update(parent: publication) + end + sample + end + + def concat_author_ids(coauthors = params[:coauthors]) + coauthor_ids = coauthors.map do |coa| + val = coa.strip + next val.to_i if val =~ /^\d+$/ + + User.where(type: %w(Person Collaborator)).where.not(confirmed_at: nil).find_by(email: val)&.id if val =~ /^\S+@\S+$/ + end.compact + [current_user.id] + coauthor_ids + end + + def duplicate_reaction(reaction, analysis_set) + new_reaction = reaction.dup + if analysis_set && analysis_set.length > 0 + analysis_set_ids = analysis_set.map(&:id) + reaction_analysis_set = reaction.analyses.where(id: analysis_set_ids) + end + princhi_string, princhi_long_key, princhi_short_key, princhi_web_key = reaction.products_rinchis + + new_reaction.collections << current_user.pending_collection + new_reaction.collections << Collection.element_to_review_collection + new_reaction.collections << @embargo_collection unless @embargo_collection.nil? + + # composer = SVG::ReactionComposer.new(paths, temperature: temperature_display_with_unit, + # solvents: solvents_in_svg, + # show_yield: true) + # new_reaction.reaction_svg_file = composer.compose_reaction_svg_and_save(prefix: Time.now) + dir = File.join(Rails.root, 'public', 'images', 'reactions') + rsf = reaction.reaction_svg_file + path = File.join(dir, rsf) + new_rsf = "#{Time.now.to_i}-#{rsf}" + dest = File.join(dir, new_rsf) + + new_reaction.save! + new_reaction.copy_segments(segments: reaction.segments, current_user_id: current_user.id) + unless @literals.nil? + lits = @literals&.select { |lit| lit['element_type'] == 'Reaction' && lit['element_id'] == reaction.id } + duplicate_literals(new_reaction, lits) + end + if File.exists? path + FileUtils.cp(path, dest) + new_reaction.update_columns(reaction_svg_file: new_rsf) + end + # new_reaction.save! + et = new_reaction.tag + data = et.taggable_data || {} + # data[:products_rinchi] = { + # rinchi_string: princhi_string, + # rinchi_long_key: princhi_long_key, + # rinchi_short_key: princhi_short_key, + # rinchi_web_key: princhi_web_key + # } + et.update!(taggable_data: data) + + if (d = reaction.doi) + d.update!(doiable: new_reaction) + else + # NB: the reaction has still no sample, so it cannot get a proper rinchi needed for the doi + # => use the one from original reaction + d = Doi.create_for_element!(new_reaction, 'reaction/' + reaction.products_short_rinchikey_trimmed) + end + + pub = Publication.create!( + state: Publication::STATE_PENDING, + element: new_reaction, + original_element: reaction, + published_by: current_user.id, + doi: d, + taggable_data: @publication_tag.merge( + author_ids: @author_ids, + original_analysis_ids: analysis_set_ids, + products_rinchi: { + rinchi_string: princhi_string, + rinchi_long_key: princhi_long_key, + rinchi_short_key: princhi_short_key, + rinchi_web_key: princhi_web_key + } + ) + ) + + duplicate_analyses(new_reaction, reaction_analysis_set, 'reaction/' + reaction.products_short_rinchikey_trimmed) + reaction.reactions_samples.each do |rs| + new_rs = rs.dup + sample = current_user.samples.find_by(id: rs.sample_id) + if @scheme_only == true + sample.target_amount_value = 0.0 + sample.real_amount_value = nil + end + sample_analysis_set = sample.analyses.where(id: analysis_set_ids) + new_sample = duplicate_sample(sample, sample_analysis_set, pub.id) + sample.tag_as_published(new_sample, sample_analysis_set) + new_rs.sample_id = new_sample + new_rs.reaction_id = new_reaction.id + new_rs.sample_id = new_sample.id + new_rs.reaction_id = new_reaction.id + new_rs.save! + end + + new_reaction.update_svg_file! + new_reaction.reload + new_reaction.save! + new_reaction.reload + end + + def create_new_reaction_version(reaction = @reaction, scheme_only = @scheme_only) + new_reaction = reaction.dup + new_reaction.previous_version = reaction + new_reaction.collections << current_user.versions_collection + new_reaction.save! + new_reaction.copy_segments(segments: reaction.segments, current_user_id: current_user.id) + + duplicate_literals(new_reaction, reaction.literals) + + dir = File.join(Rails.root, 'public', 'images', 'reactions') + rsf = reaction.reaction_svg_file + path = File.join(dir, rsf) + new_rsf = "#{Time.now.to_i}-#{rsf}" + dest = File.join(dir, new_rsf) + if File.exists? path + FileUtils.cp(path, dest) + new_reaction.update_columns(reaction_svg_file: new_rsf) + end + + link_analyses(new_reaction, reaction.analyses) + + new_reaction.update_tag!(analyses_tag: true) + + reaction.tag_as_previous_version(new_reaction) + new_reaction.tag_as_new_version(reaction, scheme_only: scheme_only) + new_reaction.update_versions_tag + + reaction.reactions_samples.each do |reaction_sample| + # look for the sample in the public collection or the scheme only reactions collection + unless scheme_only + sample = Collection.public_collection.samples.find_by(id: reaction_sample.sample_id, created_by: current_user.id) + else + sample = Collection.scheme_only_reactions_collection.samples.find_by(id: reaction_sample.sample_id, created_by: current_user.id) + end + next unless sample + + # duplicate the reaction_sample object + new_reaction_sample = reaction_sample.dup + + # check if new versions of the sample have already been created + # if yes, use the *last* sample version for the new reaction_sample + unless sample&.tag&.taggable_data['versions'].nil? + last_sample_version_id = sample&.tag&.taggable_data['versions'].max() + if last_sample_version_id > sample.id + last_sample_version = Sample.find_by(id: last_sample_version_id) + last_sample_version.update_tag!(reaction_tag: new_reaction.id) + last_sample_version.untag_replace_in_publication + + new_reaction_sample.sample_id = last_sample_version_id + end + end + + # update the new reaction sample instance + new_reaction_sample.reaction_id = new_reaction.id + new_reaction_sample.save! + + # remove sample from versions collection again, overriding the behaviour in ReactionSampleCollections + collections_sample = CollectionsSample.find_by(sample: sample, collection: @current_user.versions_collection) + collections_sample.delete unless collections_sample.nil? + end + + new_reaction.update_svg_file! + new_reaction.reload + new_reaction.save! + new_reaction.reload + new_reaction + end + + def submit_new_reaction_version(reaction = @reaction) + reaction.collections.clear + reaction.collections << current_user.pending_collection + reaction.collections << Collection.element_to_review_collection + reaction.collections << @embargo_collection unless @embargo_collection.nil? + reaction.save! + + princhi_string, princhi_long_key, princhi_short_key, princhi_web_key = reaction.products_rinchis + + # create dois and publications for analyses + reaction.analyses.each do |analysis| + # create doi for analysis + if (doi = analysis.doi) + doi.update(doiable: analysis) + else + doi = Doi.create_for_analysis!(analysis) + end + + # create publication for analyses + Publication.create!( + state: Publication::STATE_PENDING, + element: analysis, + published_by: current_user.id, + doi: doi, + taggable_data: @publication_tag.merge( + author_ids: @author_ids + ) + ) + end + + # create doi for reaction + if (doi = reaction.doi) + doi.update!(doiable: reaction) + else + doi = Doi.create_for_element!(reaction) + end + + # create publication for reaction + publication = Publication.create!( + state: Publication::STATE_PENDING, + element: reaction, + published_by: current_user.id, + doi: doi, + taggable_data: @publication_tag.merge( + author_ids: @author_ids, + products_rinchi: { + rinchi_string: princhi_string, + rinchi_long_key: princhi_long_key, + rinchi_short_key: princhi_short_key, + rinchi_web_key: princhi_web_key + } + ) + ) + + reaction.analyses.each do |analysis| + Publication.find_by(element: analysis).update(parent: publication) + end + + reaction.reactions_samples.each do |reaction_sample| + # check if this sample is a new version of a sample + new_sample_version = current_user.versions_collection.samples.find_by(id: reaction_sample.sample_id) + if new_sample_version + submit_new_sample_version(new_sample_version, parent_publication_id = publication.id) + end + end + + reaction + end + + def create_new_container_version(element = @element, analysis = @analysis) + analyses = Container.analyses_container(element.container.id).first + + previous_doi = Doi.find_by(doiable_type: 'Container', doiable_id: analysis.id) + + new_analysis = analyses.children.create( + name: analysis.name, + container_type: analysis.container_type, + description: analysis.description + ) + new_analysis.extended_metadata = analysis.extended_metadata + new_analysis.extended_metadata[:previous_version_id] = analysis.id + new_analysis.extended_metadata[:previous_version_doi_id] = previous_doi.id if previous_doi + new_analysis.save! + + analysis.extended_metadata[:new_version_id] = new_analysis.id + analysis.save! + + # duplicate datasets and copy attachments + analysis.children.where(container_type: 'dataset').each do |dataset| + new_dataset = new_analysis.children.create(container_type: 'dataset') + dataset.attachments.each do |attachment| + new_attachment = attachment.copy( + attachable_type: 'Container', + attachable_id: new_dataset.id, + transferred: true + ) + new_attachment.save! + new_dataset.attachments << new_attachment + + # copy publication image file to public/images/publications/{attachment.id}/{attachment.filename} + if MimeMagic.by_path(new_attachment.filename)&.type&.start_with?('image') + file_path = File.join('public/images/publications/', new_attachment.id.to_s, '/', new_attachment.filename) + public_path = File.join('public/images/publications/', new_attachment.id.to_s) + FileUtils.mkdir_p(public_path) + File.write(file_path, new_attachment.store.read_file.force_encoding('utf-8')) if new_attachment.store.file_exist? + end + end + + new_dataset.name = dataset.name + new_dataset.extended_metadata = dataset.extended_metadata + new_dataset.save! + end + + new_analysis + end + + def create_publication_tag(contributor, author_ids, license) + authors = User.where(type: %w[Person Collaborator], id: author_ids) + .includes(:affiliations) + .order("position(users.id::text in '#{author_ids}')") + affiliations = authors.map(&:current_affiliations) + affiliations_output = {} + affiliations.flatten.each do |aff| + affiliations_output[aff.id] = aff.output_full + end + { + published_by: author_ids[0], + author_ids: author_ids, + creators: authors.map do |author| + { + 'givenName' => author.first_name, + 'familyName' => author.last_name, + 'name' => author.name, + 'ORCID' => author.orcid, + 'affiliationIds' => author.current_affiliations.map(&:id), + 'id' => author.id + } + end, + contributors: { + 'givenName' => contributor.first_name, + 'familyName' => contributor.last_name, + 'name' => contributor.name, + 'ORCID' => contributor.orcid, + 'affiliations' => contributor.current_affiliations.map(&:output_full), + 'id' => contributor.id + }, + affiliations: affiliations_output, + affiliation_ids: affiliations.map { |as| as.map(&:id) }, + queued_at: DateTime.now, + license: license, + scheme_only: @scheme_only + } + end + + def prepare_reaction_data + reviewer_collections + + if @reaction.tag.taggable_data['previous_version'] + new_reaction = submit_new_reaction_version + else + new_reaction = duplicate_reaction(@reaction, @analysis_set) + reaction_analysis_set = @reaction.analyses.where(id: @analysis_set_ids) + @reaction.tag_as_published(new_reaction, reaction_analysis_set) + end + + new_reaction.create_publication_tag(current_user, @author_ids, @license) + new_reaction.samples.each do |new_sample| + unless new_sample.publication_tag.present? + new_sample.create_publication_tag(current_user, @author_ids, @license) + end + end + pub = Publication.where(element: new_reaction).first + add_submission_history(pub) + pub + end + + def duplicate_literals(element, literals) + literals&.each do |lit| + attributes = { + literature_id: lit.literature_id, + element_id: element.id, + element_type: lit.element_type, + category: 'detail', + user_id: lit.user_id, + litype: lit.litype + } + Literal.create(attributes) + end + end + + def prepare_sample_data + reviewer_collections + + if @sample.tag.taggable_data['previous_version'] + new_sample = submit_new_sample_version + else + new_sample = duplicate_sample(@sample, @analyses) + @sample.tag_as_published(new_sample, @analyses) + end + + new_sample.create_publication_tag(current_user, @author_ids, @license) + @sample.untag_reserved_suffix + pub = Publication.where(element: new_sample).first + add_submission_history(pub) + pub + end + + def add_submission_history(root) + init_node = { + state: 'submission', + action: 'submission', + timestamp: Time.now.strftime('%d-%m-%Y %H:%M:%S'), + username: current_user.name, + user_id: current_user.id, + type: 'submit' + } + review = root.review || {} + history = review['history'] || [] + history << init_node + + current_node = { + action: 'reviewing', + type: 'reviewed', + state: 'pending' + } + history << current_node + review['history'] = history + review['reviewers'] = @group_reviewers if @group_reviewers.present? + root.update!(review: review) + end + end + + desc 'Get review list' + params do + optional :type, type: String, desc: 'Type' + optional :state, type: String, desc: 'State' + optional :search_type, type: String, desc: 'search type', values: %w[All Name Embargo Submitter] + optional :search_value, type: String, desc: 'search value' + optional :page, type: Integer, desc: 'page' + optional :pages, type: Integer, desc: 'pages' + optional :per_page, type: Integer, desc: 'per page' + end + paginate per_page: 10, offset: 0, max_per_page: 100 + get 'list' do + type = params[:type].blank? || params[:type] == 'All' ? %w[Sample Reaction] : params[:type].chop! + state = params[:state].empty? || params[:state] == 'All' ? [Publication::STATE_PENDING, Publication::STATE_REVIEWED, Publication::STATE_ACCEPTED] : params[:state] + pub_scope = if User.reviewer_ids.include?(current_user.id) + Publication.where(state: state, ancestry: nil, element_type: type) + else + Publication.where(state: state, ancestry: nil, element_type: type).where("published_by = ? OR (review -> 'reviewers')::jsonb @> '?'", current_user.id, current_user.id) + end + unless params[:search_value].blank? || params[:search_value] == 'All' + case params[:search_type] + when 'Submitter' + pub_scope = pub_scope.where(published_by: params[:search_value]) + when 'Embargo' + embargo_search = <<~SQL + (element_type = 'Reaction' and element_id in (select reaction_id from collections_reactions cr where cr.deleted_at is null and cr.collection_id = ?)) + or + (element_type = 'Sample' and element_id in (select sample_id from collections_samples cs where cs.deleted_at is null and cs.collection_id = ?)) + SQL + embargo_search = ActiveRecord::Base.send(:sanitize_sql_array, [embargo_search, params[:search_value], params[:search_value]]) + pub_scope = pub_scope.where(embargo_search) + when 'Name' + r_name_sql = " r.short_label like '%#{ActiveRecord::Base.send(:sanitize_sql_like, params[:search_value])}%' " + s_name_sql = " s.short_label like '%#{ActiveRecord::Base.send(:sanitize_sql_like, params[:search_value])}%' " + name_search = <<~SQL + (element_type = 'Reaction' and element_id in (select id from reactions r where #{r_name_sql})) + or + (element_type = 'Sample' and element_id in (select id from samples s where #{s_name_sql})) + SQL + pub_scope = pub_scope.where(name_search) + end + end + + list = pub_scope.order('publications.updated_at desc') + elements = [] + paginate(list).each do |e| + element_type = e.element&.class&.name + next if element_type.nil? + + u = User.find(e.published_by) unless e.published_by.nil? + svg_file = e.element.reaction_svg_file if element_type == 'Reaction' + title = e.element.short_label if element_type == 'Reaction' + + svg_file = e.element.sample_svg_file if element_type == 'Sample' + title = e.element.short_label if element_type == 'Sample' + review_info = repo_review_info(e, current_user&.id, true) + checklist = e.review['checklist'] if User.reviewer_ids.include?(current_user&.id) || review_info[:groupleader] == true + scheme_only = element_type == 'Reaction' && e.taggable_data && e.taggable_data['scheme_only'] + + + elements.push( + id: e.element_id, svg: svg_file, type: element_type, title: title, checklist: checklist || {}, review_info: review_info, isReviewer: User.reviewer_ids.include?(current_user&.id) || false, + published_by: u&.name, submitter_id: u&.id, submit_at: e.created_at, state: e.state, embargo: find_embargo_collection(e).label, scheme_only: scheme_only + ) + end + { elements: elements } + end + + desc 'Get embargo list' + helpers RepositoryHelpers + post 'embargo_list' do + params do + optional :is_submit, type: Boolean, default: false, desc: 'Publication submission' + end + + if User.reviewer_ids.include?(current_user.id) && params[:is_submit] == false + es = Publication.where(element_type: 'Collection', state: 'pending').order("taggable_data->>'label' ASC") + else + cols = if current_user.type == 'Anonymous' + Collection.where(id: current_user.sync_in_collections_users.pluck(:collection_id)).where.not(label: 'chemotion') + else + Collection.where(ancestry: current_user.publication_embargo_collection.id) + end + es = Publication.where(element_type: 'Collection', element_id: cols.pluck(:id)).order("taggable_data->>'label' ASC") unless cols.empty? + end + + { repository: es, current_user: { id: current_user.id, type: current_user.type } } + end + + namespace :assign_embargo do + desc 'assign to an embargo bundle' + params do + requires :new_embargo, type: Integer, desc: 'Collection id' + requires :element, type: Hash, desc: 'Element' + end + after_validation do + declared_params = declared(params, include_missing: false) + @p_element = declared_params[:element] + @p_embargo = declared_params[:new_embargo] + pub = Publication.find_by(element_type: @p_element['type'].classify, element_id: @p_element['id']) + error!('404 Publication not found', 404) unless pub + error!("404 Publication state must be #{Publication::STATE_REVIEWED}", 404) unless pub.state == Publication::STATE_REVIEWED + error!('401 Unauthorized', 401) unless pub.published_by == current_user.id + if @p_embargo.to_i.positive? + e_col = Collection.find(@p_embargo.to_i) + error!('404 This embargo has been released.', 404) unless e_col.ancestry.to_i == current_user.publication_embargo_collection.id + end + end + post do + embargo_collection = fetch_embargo_collection(@p_embargo.to_i, current_user) if @p_embargo.to_i >= 0 + case @p_element['type'].classify + when 'Sample' + CollectionsSample + when 'Reaction' + CollectionsReaction + end.create_in_collection(@p_element['id'], [embargo_collection.id]) + { element: @p_element, + new_embargo: embargo_collection, + is_new_embargo: @p_embargo.to_i.zero?, + message: "#{@p_element['type']} [#{@p_element['title']}] has been moved to Embargo Bundle [#{embargo_collection.label}]" } + rescue StandardError => e + { error: e.message } + end + end + + resource :compound do + desc 'compound' + params do + requires :id, type: Integer, desc: 'Element id' + optional :data, type: String + end + resource :request do + post do + PublicationMailer.mail_request_compound(current_user, params[:id], params[:data], 'request').deliver_now + PublicationMailer.mail_request_compound(current_user, params[:id], params[:data], 'confirmation').deliver_now + end + end + resource :update do + after_validation do + @pub = ElementTag.find_by(taggable_type: 'Sample', taggable_id: params[:id]) + error!('404 No data found', 404) unless @pub + element_policy = ElementPolicy.new(current_user, @pub.taggable) + error!('401 Unauthorized', 401) unless element_policy.read? || User.reviewer_ids.include?(current_user.id) + end + post do + data = @pub.taggable_data || {} + xvail = data['xvial'] || {} + xvail['num'] = params[:data] + xvail['username'] = current_user.name + xvail['userid'] = current_user.id + xvail['timestamp'] = Time.now.strftime('%d-%m-%Y %H:%M:%S') + data['xvial'] = xvail + @pub.update!(taggable_data: data) + end + end + end + + resource :comment do + desc 'User comment' + params do + requires :id, type: Integer, desc: 'Element id' + optional :type, type: String, values: %w[Reaction Sample Container] + requires :pageId, type: Integer, desc: 'Page Element id' + optional :pageType, type: String, values: %w[reactions molecules] + optional :comment, type: String + end + after_validation do + @pub = Publication.find_by(element_type: params[:type], element_id: params[:id]) + error!('404 No data found', 404) unless @pub + element_policy = ElementPolicy.new(current_user, @pub.element) + error!('401 Unauthorized', 401) unless element_policy.read? || User.reviewer_ids.include?(current_user.id) + end + post 'user_comment' do + PublicationMailer.mail_user_comment(current_user, params[:id], params[:type], params[:pageId], params[:pageType], params[:comment]).deliver_now + end + post 'reviewer' do + pub = Publication.find_by(element_type: params[:type], element_id: params[:id]) + review = pub.review || {} + review_info = review['info'] || {} + review_info['comment'] = params[:comment] + review_info['timestamp'] = Time.now.strftime('%d-%m-%Y %H:%M:%S') + review_info['username'] = current_user.name + review_info['userid'] = current_user.id + + review['info'] = review_info + pub.update!(review: review) + end + end + + resource :reaction do + helpers RepositoryHelpers + desc 'Return PUBLISHED serialized reaction' + params do + requires :id, type: Integer, desc: 'Reaction id' + optional :is_public, type: Boolean, default: true + end + after_validation do + element = Reaction.find_by(id: params[:id]) + error!('404 No data found', 404) unless element + element_policy = ElementPolicy.new(current_user, element) + error!('401 Unauthorized', 401) unless element_policy.read? || User.reviewer_ids.include?(current_user.id) + end + get do + reaction = Reaction.where(id: params[:id]) + .select( + <<~SQL + reactions.id, reactions.name, reactions.description, reactions.reaction_svg_file, reactions.short_label, + reactions.status, reactions.tlc_description, reactions.tlc_solvents, reactions.rf_value, + reactions.temperature, reactions.timestamp_start,reactions.timestamp_stop,reactions.observation, + reactions.rinchi_string, reactions.rinchi_long_key, reactions.rinchi_short_key,reactions.rinchi_web_key, + (select json_extract_path(taggable_data::json, 'publication') from publications where element_type = 'Reaction' and element_id = reactions.id) as publication, + reactions.duration + SQL + ).includes(container: :attachments).last + literatures = get_literature(params[:id], 'Reaction', params[:is_public] ? 'public' : 'detail') || [] + reaction.products.each do |p| + literatures += get_literature(p.id, 'Sample', params[:is_public] ? 'public' : 'detail') + end + schemeList = get_reaction_table(params[:id]) + publication = Publication.find_by(element_id: params[:id], element_type: 'Reaction') + review_info = repo_review_info(publication, current_user&.id, false) + publication.review&.slice!('history') unless User.reviewer_ids.include?(current_user.id) || review_info[:groupleader] == true + published_user = User.find(publication.published_by) unless publication.nil? + entities = Entities::ReactionEntity.represent(reaction, serializable: true) + entities[:literatures] = literatures unless entities.nil? || literatures.blank? + entities[:schemes] = schemeList unless entities.nil? || schemeList.blank? + entities[:segments] = Entities::SegmentEntity.represent(reaction.segments) + embargo = find_embargo_collection(publication) + entities[:embargo] = embargo&.label + entities[:embargoId] = embargo&.id + { + reaction: entities, + selectEmbargo: Publication.find_by(element_type: 'Collection', element_id: embargo&.id), + pub_name: published_user&.name || '', + review_info: review_info + } + end + end + + resource :sample do + helpers RepositoryHelpers + desc 'Return Review serialized Sample' + params do + requires :id, type: Integer, desc: 'Sample id' + optional :is_public, type: Boolean, default: true + end + after_validation do + element = Sample.find_by(id: params[:id]) + error!('401 No data found', 401) unless element + element_policy = ElementPolicy.new(current_user, element) + error!('401 Unauthorized', 401) unless element_policy.read? || User.reviewer_ids.include?(current_user.id) + end + get do + sample = Sample.where(id: params[:id]).includes(:molecule, :tag).last + review_sample = { **sample.serializable_hash.deep_symbolize_keys } + review_sample[:segments] = sample.segments.present? ? Entities::SegmentEntity.represent(sample.segments) : [] + molecule = Molecule.find(sample.molecule_id) unless sample.nil? + containers = Entities::ContainerEntity.represent(sample.container) + publication = Publication.find_by(element_id: params[:id], element_type: 'Sample') + review_info = repo_review_info(publication, current_user&.id, false) + # preapproved = publication.review.dig('checklist', 'glr', 'status') == true + # is_leader = publication.review.dig('reviewers')&.include?(current_user&.id) + publication.review&.slice!('history') unless User.reviewer_ids.include?(current_user.id) || review_info[:groupleader] == true + published_user = User.find(publication.published_by) unless publication.nil? + literatures = get_literature(params[:id], 'Sample', params[:is_public] ? 'public' : 'detail') + # embargo = PublicationCollections.where("(elobj ->> 'element_type')::text = 'Sample' and (elobj ->> 'element_id')::integer = #{sample.id}")&.first&.label + embargo = find_embargo_collection(publication) + review_sample[:embargo] = embargo&.label + review_sample[:embargoId] = embargo&.id + + { + molecule: MoleculeGuestSerializer.new(molecule).serializable_hash.deep_symbolize_keys, + sample: review_sample, + publication: publication, + literatures: literatures, + analyses: containers, + selectEmbargo: Publication.find_by(element_type: 'Collection', element_id: embargo&.id), + doi: Entities::DoiEntity.represent(sample.doi, serializable: true), + pub_name: published_user&.name, + review_info: review_info + } + end + end + + resource :metadata do + desc 'metadata of publication' + params do + requires :id, type: Integer, desc: 'Id' + requires :type, type: String, desc: 'Type', values: %w[sample reaction] + end + after_validation do + @root_publication = Publication.find_by( + element_type: params['type'].classify, + element_id: params['id'] + ).root + error!('404 Publication not found', 404) unless @root_publication + error!('401 Unauthorized', 401) unless User.reviewer_ids.include?(current_user.id) || @root_publication.published_by == current_user.id || @root_publication.review['reviewers'].include?(current_user.id) + end + post :preview do + mt = [] + root_publication = @root_publication + publications = [root_publication] + root_publication.descendants + publications.each do |pub| + mt.push(element_type: pub.element_type, metadata_xml: pub.datacite_metadata_xml) + end + { metadata: mt } + end + post :preview_zip do + env['api.format'] = :binary + content_type('application/zip, application/octet-stream') + root_publication = @root_publication + publications = [root_publication] + root_publication.descendants + filename = URI.escape("metadata_#{root_publication.element_type}_#{root_publication.element_id}-#{Time.new.strftime('%Y%m%d%H%M%S')}.zip") + header('Content-Disposition', "attachment; filename=\"#{filename}\"") + zip = Zip::OutputStream.write_buffer do |zip| + publications.each do |pub| + el_type = pub.element_type == 'Container' ? 'analysis' : pub.element_type.downcase + zip.put_next_entry URI.escape("metadata_#{el_type}_#{pub.element_id}.xml") + zip.write pub.datacite_metadata_xml + end + end + zip.rewind + zip.read + end + end + namespace :review_search_options do + helpers do + def query_submitter(element_type, state) + if User.reviewer_ids.include?(current_user.id) + state_sql = state == 'All' || state.empty? ? " state in ('pending', 'reviewed', 'accepted')" : ActiveRecord::Base.send(:sanitize_sql_array, [' state=? ', state]) + type_sql = element_type == 'All' || element_type.empty? ? " element_type in ('Sample', 'Reaction')" : ActiveRecord::Base.send(:sanitize_sql_array, [' element_type=? ', element_type.chop]) + search_scope = User.where(type: 'Person').where( + <<~SQL + users.id in ( + select published_by from publications pub where ancestry is null and deleted_at is null + and #{state_sql} and #{type_sql}) + SQL + ) + .order('first_name ASC') + else + search_scope = User.where(id: current_user.id) + end + result = search_scope.select( + <<~SQL + id as key, first_name, last_name, first_name || chr(32) || last_name as name, first_name || chr(32) || last_name || chr(32) || '(' || name_abbreviation || ')' as label + SQL + ) + end + + def query_embargo + search_scope = if User.reviewer_ids.include?(current_user.id) + Collection.where( + <<~SQL + ancestry::integer in (select id from collections cx where label = 'Embargoed Publications') + SQL + ) + else + Collection.where(ancestry: current_user.publication_embargo_collection.id) + end + result = search_scope.select( + <<~SQL + id as key, label as name, label as label + SQL + ) + .order('label ASC') + end + end + desc 'Find matched review values' + params do + requires :type, type: String, allow_blank: false, desc: 'Type', values: %w[All Submitter Embargo] + optional :element_type, type: String, desc: 'Type', values: %w[All Samples Reactions] + optional :state, type: String, desc: 'Type', values: %w[All reviewed accepted pending] + end + get do + result = case params[:type] + when 'Submitter' + query_submitter(params[:element_type], params[:state]) + when 'Embargo' + query_embargo + else + [] + end + { result: result } + end + end + + namespace :reviewing do + helpers do + def approve_comments(root, comment, _checklist, _reviewComments, _action, _his = true) + review = root.review || {} + review_history = review['history'] || [] + current = review_history.last + current['username'] = current_user.name + current['userid'] = current_user.id + current['action'] = 'pre-approved' + current['comment'] = comment unless comment.nil? + current['timestamp'] = Time.now.strftime('%d-%m-%Y %H:%M:%S') + review_history[review_history.length - 1] = current + next_node = { action: 'reviewing', type: 'reviewed', state: 'pending' } + review_history << next_node + review['history'] = review_history + revst = review['checklist'] || {} + revst['glr'] = { status: true, user: current_user.name, updated_at: Time.now.strftime('%d-%m-%Y %H:%M:%S') } + review['checklist'] = revst + + root.update!(review: review) + end + + def save_comments(root, comment, checklist, reviewComments, action, his = true) + review = root.review || {} + review_history = review['history'] || [] + current = review_history.last + + current['state'] = %w[accepted declined].include?(action) ? action : root.state + current['action'] = action unless action.nil? + current['username'] = current_user.name + current['userid'] = current_user.id + current['comment'] = comment unless comment.nil? + current['type'] = root.state == Publication::STATE_PENDING ? 'reviewed' : 'submit' + current['timestamp'] = Time.now.strftime('%d-%m-%Y %H:%M:%S') + + review_history[review_history.length - 1] = current + if his ## add next_node + next_node = { action: 'revising', type: 'submit', state: 'reviewed' } if root.state == Publication::STATE_PENDING + next_node = { action: 'reviewing', type: 'reviewed', state: 'pending' } if root.state == Publication::STATE_REVIEWED + review_history << next_node + review['history'] = review_history + end + if checklist&.length&.positive? + revst = review['checklist'] || {} + checklist.each do |k, v| + revst[k] = v['status'] == true ? { status: v['status'], user: current_user.name, updated_at: Time.now.strftime('%d-%m-%Y %H:%M:%S') } : { status: false } unless revst[k] && revst[k]['status'] == v['status'] + end + review['checklist'] = revst + end + review['reviewComments'] = reviewComments if reviewComments.present? + root.update!(review: review) + end + + # TODO: mv to model + def save_comment(root, comment) + review = root.review || {} + review_history = review['history'] || [] + current = review_history.last + comments = current['comments'] || {} + comment[comment.keys[0]]['timestamp'] = Time.now.strftime('%d-%m-%Y %H:%M:%S') unless comment.keys.empty? + comment[comment.keys[0]]['username'] = current_user.name + comment[comment.keys[0]]['userid'] = current_user.id + + current['comments'] = comments.deep_merge(comment || {}) + review['history'] = review_history + root.update!(review: review) + end + end + + desc 'process reviewed publication' + params do + requires :id, type: Integer, desc: 'Id' + requires :type, type: String, desc: 'Type', values: %w[sample reaction] + optional :comments, type: Hash + optional :comment, type: String + optional :checklist, type: Hash + end + + after_validation do + @root_publication = Publication.find_by( + element_type: params['type'].classify, + element_id: params['id'] + ).root + error!('401 Unauthorized', 401) unless (User.reviewer_ids.include?(current_user.id) && @root_publication.state == Publication::STATE_PENDING) || (@root_publication.review.dig('reviewers')&.include?(current_user&.id)) || (@root_publication.published_by == current_user.id && @root_publication.state == Publication::STATE_REVIEWED) + end + + post :comments do + save_comments(@root_publication, params[:comment], params[:checklist], params[:reviewComments], nil, false) + element = ReactionSerializer.new(@root_publication.element).serializable_hash.deep_symbolize_keys if params[:type] == 'reaction' + element = SampleSerializer.new(@root_publication.element).serializable_hash.deep_symbolize_keys if params[:type] == 'sample' + review_info = repo_review_info(@root_publication, current_user&.id, false) + his = @root_publication.review&.slice('history') unless User.reviewer_ids.include?(current_user.id) || @root_publication.review.dig('reviewers')&.include?(current_user.id) + { "#{params[:type]}": element, review: his || @root_publication.review, review_info: review_info } + end + + post :comment do + save_comment(@root_publication, params[:comments]) unless params[:comments].nil? + his = @root_publication.review&.slice('history') unless User.reviewer_ids.include?(current_user.id) + { review: his || @root_publication.review } + end + + post :reviewed do + save_comments(@root_publication, params[:comment], params[:checklist], params[:reviewComments], 'review') + # element_submit(@root_publication) + @root_publication.update_state(Publication::STATE_REVIEWED) + @root_publication.process_element(Publication::STATE_REVIEWED) + @root_publication.inform_users(Publication::STATE_REVIEWED) + # @root_publication.element + element = ReactionSerializer.new(@root_publication.element).serializable_hash.deep_symbolize_keys if params[:type] == 'reaction' + element = SampleSerializer.new(@root_publication.element).serializable_hash.deep_symbolize_keys if params[:type] == 'sample' + review_info = repo_review_info(@root_publication, current_user&.id, false) + { "#{params[:type]}": element, review: @root_publication.review, review_info: review_info } + end + + post :submit do + save_comments(@root_publication, params[:comment], params[:checklist], params[:reviewComments], 'revision') + element_submit(@root_publication) + @root_publication.update_state(Publication::STATE_PENDING) + @root_publication.process_element(Publication::STATE_PENDING) + @root_publication.inform_users(Publication::STATE_PENDING) + element = ReactionSerializer.new(@root_publication.element).serializable_hash.deep_symbolize_keys if params[:type] == 'reaction' + element = SampleSerializer.new(@root_publication.element).serializable_hash.deep_symbolize_keys if params[:type] == 'sample' + his = @root_publication.review&.slice('history') unless User.reviewer_ids.include?(current_user.id) || @root_publication.review.dig('reviewers')&.include?(current_user.id) + review_info = repo_review_info(@root_publication, current_user&.id, false) + { "#{params[:type]}": element, review: his || @root_publication.review, review_info: review_info } + end + + post :approved do + approve_comments(@root_publication, params[:comment], params[:checklist], params[:reviewComments], 'approved', false) + element = ReactionSerializer.new(@root_publication.element).serializable_hash.deep_symbolize_keys if params[:type] == 'reaction' + element = SampleSerializer.new(@root_publication.element).serializable_hash.deep_symbolize_keys if params[:type] == 'sample' + his = @root_publication.review&.slice('history') unless User.reviewer_ids.include?(current_user.id) || @root_publication.review.dig('reviewers')&.include?(current_user.id) + review_info = repo_review_info(@root_publication, current_user&.id, false) + { "#{params[:type]}": element, review: his || @root_publication.review, review_info: review_info } + end + + post :accepted do + save_comments(@root_publication, params[:comment], params[:checklist], params[:reviewComments], 'accepted', false) + element_submit(@root_publication) + public_literature(@root_publication) + # element_accepted(@root_publication) + + @root_publication.update_state(Publication::STATE_ACCEPTED) + @root_publication.process_element(Publication::STATE_ACCEPTED) + @root_publication.inform_users(Publication::STATE_ACCEPTED) + + element = ReactionSerializer.new(@root_publication.element).serializable_hash.deep_symbolize_keys if params[:type] == 'reaction' + element = SampleSerializer.new(@root_publication.element).serializable_hash.deep_symbolize_keys if params[:type] == 'sample' + review_info = repo_review_info(@root_publication, current_user&.id, false) + { "#{params[:type]}": element, review: @root_publication.review, message: ENV['PUBLISH_MODE'] ? "publication on: #{ENV['PUBLISH_MODE']}" : 'publication off', review_info: review_info } + end + post :declined do + save_comments(@root_publication, params[:comment], params[:checklist], params[:reviewComments], 'declined', false) + @root_publication.update_state('declined') + @root_publication.process_element('declined') + @root_publication.inform_users(Publication::STATE_DECLINED, current_user.id) + element = ReactionSerializer.new(@root_publication.element).serializable_hash.deep_symbolize_keys if params[:type] == 'reaction' + element = SampleSerializer.new(@root_publication.element).serializable_hash.deep_symbolize_keys if params[:type] == 'sample' + his = @root_publication.review&.slice('history') unless User.reviewer_ids.include?(current_user.id) + { "#{params[:type]}": element, review: his || @root_publication.review } + end + end + + namespace :publishSample do + desc 'Publish Samples with chosen Dataset' + params do + requires :sampleId, type: Integer, desc: 'Sample Id' + requires :analysesIds, type: Array[Integer], desc: 'Selected analyses ids' + optional :coauthors, type: Array[String], default: [], desc: 'Co-author (User)' + optional :reviewers, type: Array[String], default: [], desc: 'reviewers (User)' + optional :refs, type: Array[Integer], desc: 'Selected references' + optional :embargo, type: Integer, desc: 'Embargo collection' + requires :license, type: String, desc: 'Creative Common License' + requires :addMe, type: Boolean, desc: 'add me as author' + end + + after_validation do + @sample = current_user.samples.find_by(id: params[:sampleId]) + unless @sample + @sample = current_user.versions_collection.samples.find_by(id: params[:sampleId]) + end + error!('401 Unauthorized', 401) unless @sample + + analyses = @sample&.analyses&.where(id: params[:analysesIds]) + links = @sample&.links&.where(id: params[:analysesIds]) + + @analyses = analyses ? analyses.or(links) : links + error!('404 analyses not found', 404) if @analyses.empty? + + @literals = Literal.where(id: params[:refs]) unless params[:refs].nil? || params[:refs].empty? + ols_validation(@analyses) + @author_ids = if params[:addMe] + [current_user.id] + coauthor_validation(params[:coauthors]) + else + coauthor_validation(params[:coauthors]) + end + @group_reviewers = coauthor_validation(params[:reviewers]) + + previous_version = @sample&.tag&.taggable_data['previous_version'] + if previous_version + error!('400 license does not match previous version', 400) unless previous_version['license'] == params[:license] + end + end + + post do + @license = params[:license] + @publication_tag = create_publication_tag(current_user, @author_ids, @license) + @embargo_collection = fetch_embargo_collection(params[:embargo], current_user) if params[:embargo].present? && params[:embargo] >= 0 + pub = prepare_sample_data + pub.process_element + update_tag_doi(pub.element) + pub.inform_users + + @sample.reload + { + sample: SampleSerializer.new(@sample).serializable_hash.deep_symbolize_keys, + message: ENV['PUBLISH_MODE'] ? "publication on: #{ENV['PUBLISH_MODE']}" : 'publication off' + } + end + + put :dois do + @sample.reserve_suffix + @sample.reserve_suffix_analyses(@analyses) + @sample.reload + @sample.tag_reserved_suffix(@analyses) + { sample: SampleSerializer.new(@sample).serializable_hash.deep_symbolize_keys } + end + end + + namespace :createNewSampleVersion do + desc 'Create a new version of a published Sample' + params do + requires :sampleId, type: Integer, desc: 'Sample Id' + optional :reactionId, type: Integer, desc: 'Reaction Id' + end + + after_validation do + # look for the sample in all public samples created by the current user + @sample = Collection.public_collection.samples.find_by(id: params[:sampleId], created_by: current_user.id) + error!('401 Unauthorized', 401) unless @sample + + # look for an optional reaction in the public collection or the versions_collection of the current user + unless params[:reactionId].nil? + @reaction = Collection.public_collection.reactions.find_by(id: params[:reactionId]) + unless @reaction + @reaction = current_user.versions_collection.reactions.find_by(id: params[:reactionId]) + end + + error!('400 reaction not found', 404) unless @reaction + error!('400 sample not part of reaction', 404) unless @reaction.samples.find_by(id: @sample.id) + end + end + + post do + new_sample = create_new_sample_version + { + sample: SampleSerializer.new(new_sample).serializable_hash.deep_symbolize_keys, + message: ENV['PUBLISH_MODE'] ? "publication on: #{ENV['PUBLISH_MODE']}" : 'publication off' + } + end + end + + # desc: submit reaction data for publication + namespace :publishReaction do + desc 'Publish Reaction with chosen Dataset' + params do + requires :reactionId, type: Integer, desc: 'Reaction Id' + requires :analysesIds, type: Array[Integer], desc: 'Selected analyses ids' + optional :coauthors, type: Array[String], default: [], desc: 'Co-author (User)' + optional :reviewers, type: Array[String], default: [], desc: 'reviewers (User)' + optional :refs, type: Array[Integer], desc: 'Selected references' + optional :embargo, type: Integer, desc: 'Embargo collection' + requires :license, type: String, desc: 'Creative Common License' + requires :addMe, type: Boolean, desc: 'add me as author' + end + + after_validation do + @scheme_only = false + @reaction = current_user.reactions.find_by(id: params[:reactionId]) + unless @reaction + @reaction = current_user.versions_collection.reactions.find_by(id: params[:reactionId]) + error!('404 found no reaction to publish', 404) unless @reaction + end + @analysis_set = @reaction.analyses.where(id: params[:analysesIds]) | @reaction&.links&.where(id: params[:analysesIds]) \ + | Container.where(id: (@reaction.samples.map(&:analyses).flatten.map(&:id) & params[:analysesIds])) \ + | Container.where(id: (@reaction.samples.map(&:links).flatten.map(&:id) & params[:analysesIds])) + + ols_validation(@analysis_set) + @author_ids = if params[:addMe] + [current_user.id] + coauthor_validation(params[:coauthors]) + else + coauthor_validation(params[:coauthors]) + end + error!('404 found no analysis to publish', 404) unless @analysis_set.present? + + @group_reviewers = coauthor_validation(params[:reviewers]) + # error!('Reaction Publication not authorized', 401) + @analysis_set_ids = @analysis_set.map(&:id) + @literals = Literal.where(id: params[:refs]) unless params[:refs].nil? || params[:refs].empty? + + previous_license = @reaction&.tag&.taggable_data['previous_license'] + if previous_license + error!('400 license does not match previous version', 400) unless previous_license == params[:license] + end + end + + post do + @license = params[:license] + @publication_tag = create_publication_tag(current_user, @author_ids, @license) + @embargo_collection = fetch_embargo_collection(params[:embargo], current_user) if params[:embargo].present? && params[:embargo] >= 0 + pub = prepare_reaction_data + pub.process_element + update_tag_doi(pub.element) + pub.inform_users + + @reaction.reload + { + reaction: ReactionSerializer.new(@reaction).serializable_hash.deep_symbolize_keys, + message: ENV['PUBLISH_MODE'] ? "publication on: #{ENV['PUBLISH_MODE']}" : 'publication off' + } + end + + put :dois do + reaction_products = @reaction.products.select { |s| s.analyses.select { |a| a.id.in? @analysis_set_ids }.count > 0 } + @reaction.reserve_suffix + reaction_products.each do |p| + d = p.reserve_suffix + et = p.tag + et.update!( + taggable_data: (et.taggable_data || {}).merge(reserved_doi: d.full_doi) + ) + end + @reaction.reserve_suffix_analyses(@analysis_set) + @reaction.reload + @reaction.tag_reserved_suffix(@analysis_set) + @reaction.reload + { + reaction: ReactionSerializer.new(@reaction).serializable_hash.deep_symbolize_keys, + message: ENV['PUBLISH_MODE'] ? "publication on: #{ENV['PUBLISH_MODE']}" : 'publication off' + } + end + end + + namespace :createNewReactionVersion do + desc 'Create a new version of a published reaction' + params do + requires :reactionId, type: Integer, desc: 'Reaction Id' + end + + after_validation do + @scheme_only = false + @reaction = Collection.public_collection.reactions.find_by(id: params[:reactionId], created_by: current_user.id) + error!('401 Unauthorized', 401) unless @reaction + end + + post do + new_reaction = create_new_reaction_version + { + reaction: ReactionSerializer.new(new_reaction).serializable_hash.deep_symbolize_keys, + message: ENV['PUBLISH_MODE'] ? "publication on: #{ENV['PUBLISH_MODE']}" : 'publication off' + } + end + end + + namespace :save_repo_authors do + desc 'Save REPO authors' + params do + requires :elementId, type: Integer, desc: 'Element Id' + requires :elementType, type: String, desc: 'Element Type' + requires :taggData, type: Hash do + requires :creators, type: Array[Hash] + requires :affiliations, type: Hash + requires :contributors, type: Hash + end + end + + after_validation do + unless User.reviewer_ids.include?(current_user.id) + @pub = Publication.find_by(element_id: params[:elementId], element_type: params[:elementType], published_by: current_user.id) + error!('404 No publication found', 404) unless @pub + end + end + + post do + pub = Publication.find_by(element_id: params[:elementId], element_type: params[:elementType]) + declared_params = declared(params, include_missing: false) + + et = ElementTag.find_or_create_by(taggable_id: declared_params[:elementId], taggable_type: declared_params[:elementType]) + tagg_data = declared_params[:taggData] || {} + + tagg_data['author_ids'] = tagg_data['creators']&.map { |cr| cr['id'] } + tagg_data['affiliation_ids'] = tagg_data['creators']&.map { |cr| cr['affiliationIds'] }.flatten.uniq + tagg_data['affiliations'] = tagg_data['affiliations']&.select { |k, _| tagg_data['affiliation_ids'].include?(k.to_i) } + + pub_taggable_data = pub.taggable_data || {} + pub_taggable_data = pub_taggable_data.deep_merge(tagg_data || {}) + pub.update(taggable_data: pub_taggable_data) + + et_taggable_data = et.taggable_data || {} + pub_tag = et_taggable_data['publication'] || {} + pub_tag = pub_tag.deep_merge(tagg_data || {}) + et_taggable_data['publication'] = pub_tag + et.update(taggable_data: et_taggable_data) + end + end + + # desc: submit reaction data (scheme only) for publication + namespace :publishReactionScheme do + desc 'Publish Reaction Scheme only' + params do + requires :reactionId, type: Integer, desc: 'Reaction Id' + requires :temperature, type: Hash, desc: 'Temperature' + requires :duration, type: Hash, desc: 'Duration' + requires :products, type: Array, desc: 'Products' + optional :coauthors, type: Array[String], default: [], desc: 'Co-author (User)' + optional :embargo, type: Integer, desc: 'Embargo collection' + requires :license, type: String, desc: 'Creative Common License' + requires :addMe, type: Boolean, desc: 'add me as author' + requires :schemeDesc, type: Boolean, desc: 'publish scheme' + end + + after_validation do + @scheme_only = true + @reaction = current_user.reactions.find_by(id: params[:reactionId]) + unless @reaction + @reaction = current_user.versions_collection.reactions.find_by(id: params[:reactionId]) + error!('404 found no reaction to publish', 404) unless @reaction + end + schemeYield = params[:products]&.map { |v| v.slice(:id, :_equivalent) } + @reaction.reactions_samples.select { |rs| rs.type == 'ReactionsProductSample' }.map do |p| + py = schemeYield.select { |o| o['id'] == p.sample_id } + p.equivalent = py[0]['_equivalent'] if py && !py.empty? + p.scheme_yield = py[0]['_equivalent'] if py && !py.empty? + end + + @reaction.reactions_samples.select{ |rs| rs.type != 'ReactionsProductSample' }.map do |p| + p.equivalent = 0 + end + @reaction.name = '' + @reaction.purification = '{}' + @reaction.dangerous_products = '{}' + @reaction.description = { 'ops' => [{ 'insert' => '' }] } unless params[:schemeDesc] + @reaction.observation = { 'ops' => [{ 'insert' => '' }] } + @reaction.tlc_solvents = '' + @reaction.tlc_description = '' + @reaction.rf_value = 0 + @reaction.rxno = nil + @reaction.role = '' + @reaction.temperature = params[:temperature] + @reaction.duration = "#{params[:duration][:dispValue]} #{params[:duration][:dispUnit]}" unless params[:duration].nil? + @author_ids = if params[:addMe] + [current_user.id] + coauthor_validation(params[:coauthors]) + else + coauthor_validation(params[:coauthors]) + end + end + + post do + @license = params[:license] + @publication_tag = create_publication_tag(current_user, @author_ids, @license) + @embargo_collection = fetch_embargo_collection(params[:embargo], current_user) if params[:embargo].present? && params[:embargo] >= 0 + pub = prepare_reaction_data + pub.process_element + pub.inform_users + @reaction.reload + { + reaction: ReactionSerializer.new(@reaction).serializable_hash.deep_symbolize_keys, + message: ENV['PUBLISH_MODE'] ? "publication on: #{ENV['PUBLISH_MODE']}" : 'publication off' + } + end + end + + namespace :createNewReactionSchemeVersion do + desc 'Create a new version of a published scheme only reaction' + params do + requires :reactionId, type: Integer, desc: 'Reaction Id' + end + + after_validation do + @scheme_only = true + @reaction = Collection.scheme_only_reactions_collection.reactions.find_by(id: params[:reactionId], created_by: current_user.id) + error!('401 Unauthorized', 401) unless @reaction + end + + post do + new_reaction = create_new_reaction_version + { + reaction: ReactionSerializer.new(new_reaction).serializable_hash.deep_symbolize_keys, + message: ENV['PUBLISH_MODE'] ? "publication on: #{ENV['PUBLISH_MODE']}" : 'publication off' + } + end + end + + namespace :createAnalysisVersion do + desc 'Create a new version of a linked analysis' + params do + requires :analysisId, type: Integer, desc: 'Analysis Id' + requires :linkId, type: Integer, desc: 'Link Id' + requires :parentType, type: String, desc: 'Parent Type' + requires :parentId, type: Integer, desc: 'Parent Id' + end + + after_validation do + # look for the element in the versions samples/reactions created by the current user + if params[:parentType] == 'sample' + @element = current_user.versions_collection.samples.find_by(id: params[:parentId], created_by: current_user.id) + elsif params[:parentType] == 'reaction' + @element = current_user.versions_collection.reactions.find_by(id: params[:parentId], created_by: current_user.id) + end + error!('401 Unauthorized', 401) unless @element + + # look for the link in the containers links (mainly for validation) + @link = @element.links.find_by(id: params[:linkId]) + error!('401 Unauthorized', 401) unless @link or @link&.extended_metadata['target_id'] != params[:analysisId] + + # look for the actual container + @analysis = Container.find_by(id: params[:analysisId]) + error!('401 Unauthorized', 401) unless @analysis + end + + post do + # duplicate the linked container for the element + create_new_container_version + + # remove the link + Container.destroy(@link.id) + + # return the sample or the reaction + if params[:parentType] == 'sample' + { + sample: SampleSerializer.new(@element).serializable_hash.deep_symbolize_keys, + message: ENV['PUBLISH_MODE'] ? "publication on: #{ENV['PUBLISH_MODE']}" : 'publication off' + } + elsif params[:parentType] == 'reaction' + { + reaction: ReactionSerializer.new(@element).serializable_hash.deep_symbolize_keys, + message: ENV['PUBLISH_MODE'] ? "publication on: #{ENV['PUBLISH_MODE']}" : 'publication off' + } + end + end + end + + namespace :embargo do + helpers do + def handle_embargo_collections(col) + col.update_columns(ancestry: current_user.published_collection.id) + sync_emb_col = col.sync_collections_users.where(user_id: current_user.id)&.first + sync_published_col = SyncCollectionsUser.joins("INNER JOIN collections ON collections.id = sync_collections_users.collection_id ") + .where("collections.label='Published Elements'") + .where("sync_collections_users.user_id = #{current_user.id}").first + sync_emb_col.update_columns(fake_ancestry: sync_published_col.id) + end + + def remove_anonymous(col) + anonymous_ids = col.sync_collections_users.joins("INNER JOIN users on sync_collections_users.user_id = users.id") + .where("users.type='Anonymous'").pluck(:user_id) + anonymous_ids.each do |anonymous_id| + anonymous = Anonymous.find(anonymous_id) + anonymous.sync_in_collections_users.destroy_all + anonymous.collections.each { |c| c.really_destroy! } + anonymous.really_destroy! + end + end + + def remove_embargo_collection(col) + col&.publication.really_destroy! + col.sync_collections_users.destroy_all + col.really_destroy! + end + end + desc 'Generate account with chosen Embargo' + params do + requires :collection_id, type: Integer, desc: 'Embargo Collection Id' + end + + after_validation do + @embargo_collection = Collection.find_by(id: params[:collection_id]) + error!('404 collection not found', 404) unless @embargo_collection + unless User.reviewer_ids.include?(current_user.id) + @sync_emb_col = @embargo_collection.sync_collections_users.where(user_id: current_user.id)&.first + error!('404 found no collection', 404) unless @sync_emb_col + end + end + + get :list do + sample_list = Publication.where(ancestry: nil, element: @embargo_collection.samples).order(updated_at: :desc) + reaction_list = Publication.where(ancestry: nil, element: @embargo_collection.reactions).order(updated_at: :desc) + list = sample_list + reaction_list + elements = [] + list.each do |e| + element_type = e.element&.class&.name + u = User.find(e.published_by) unless e.published_by.nil? + svg_file = e.element.sample_svg_file if element_type == 'Sample' + title = e.element.short_label if element_type == 'Sample' + + svg_file = e.element.reaction_svg_file if element_type == 'Reaction' + title = e.element.short_label if element_type == 'Reaction' + + scheme_only = element_type == 'Reaction' && e.taggable_data && e.taggable_data['scheme_only'] + elements.push( + id: e.element_id, svg: svg_file, type: element_type, title: title, + published_by: u&.name, submit_at: e.created_at, state: e.state, scheme_only: scheme_only + ) + end + { elements: elements, embargo_id: params[:collection_id], current_user: { id: current_user.id, type: current_user.type } } + end + + post :account do + begin + # create Anonymous user + name_abbreviation = "e#{SecureRandom.random_number(9999)}" + email = "#{@embargo_collection.id}.#{name_abbreviation}@chemotion.net" + pwd = Devise.friendly_token.first(8) + first_name = 'External' + last_name = 'Chemotion' + type = 'Anonymous' + + params = { email: email, password: pwd, first_name: first_name, last_name: last_name, type: type, name_abbreviation: name_abbreviation, confirmed_at: Time.now } + new_obj = User.create!(params) + new_obj.profile.update!({data: {}}) + # sync collection with Anonymous user + chemotion_user = User.chemotion_user + root_label = 'with %s' %chemotion_user.name_abbreviation + rc = Collection.find_or_create_by(user: new_obj, shared_by_id: chemotion_user.id, is_locked: true, is_shared: true, label: root_label) + + # Chemotion Collection + SyncCollectionsUser.find_or_create_by(user: new_obj, shared_by_id: chemotion_user.id, collection_id: Collection.public_collection_id, + permission_level: 0, sample_detail_level: 10, reaction_detail_level: 10, fake_ancestry: rc.id.to_s) + + + SyncCollectionsUser.find_or_create_by(user: new_obj, shared_by_id: chemotion_user.id, collection_id: @embargo_collection.id, + permission_level: 0, sample_detail_level: 10, reaction_detail_level: 10, fake_ancestry: rc.id.to_s) + + # send mail + if ENV['PUBLISH_MODE'] == 'production' + PublicationMailer.mail_external_review(current_user, @embargo_collection.label, email, pwd).deliver_now + end + + { message: 'A temporary account has been created' } + rescue StandardError => e + { error: e.message } + end + end + + post :release do + begin + col_pub = @embargo_collection.publication + if col_pub.nil? || col_pub.published_by != current_user.id + { error: "only the owner of embargo #{@embargo_collection.label} can perform the release."} + else + col_pub.update(accepted_at: Time.now.utc) + refresh_embargo_metadata(params[:collection_id]) + pub_samples = Publication.where(ancestry: nil, element: @embargo_collection.samples).order(updated_at: :desc) + pub_reactions = Publication.where(ancestry: nil, element: @embargo_collection.reactions).order(updated_at: :desc) + pub_list = pub_samples + pub_reactions + + check_state = pub_list.select { |pub| pub.state != Publication::STATE_ACCEPTED } + if check_state.present? + { error: "Embargo #{@embargo_collection.label} release failed, because not all elements have been 'accepted'."} + else + scheme_only_list = pub_list.select { |pub| pub.taggable_data['scheme_only'] == true } + if pub_list.flatten.length == scheme_only_list.flatten.length + col_pub.update(state: 'scheme_only') + else + col_pub.update(state: 'accepted') + end + + pub_list.each { |pub| element_submit(pub) } + remove_anonymous(@embargo_collection) + handle_embargo_collections(@embargo_collection) + case ENV['PUBLISH_MODE'] + when 'production' + if Rails.env.production? + ChemotionEmbargoPubchemJob.set(queue: "publishing_embargo_#{@embargo_collection.id}").perform_later(@embargo_collection.id) + end + when 'staging' + ChemotionEmbargoPubchemJob.perform_now(@embargo_collection.id) + else 'development' + end + + + { message: "Embargo #{@embargo_collection.label} has been released" } + end + end + rescue StandardError => e + { error: e.message } + end + end + + post :delete do + begin + element_cnt = @embargo_collection.samples.count + @embargo_collection.reactions.count + if element_cnt.positive? + { error: "Delete Embargo #{@embargo_collection.label} deletion failed: the collection is not empty. Please refresh your page."} + else + remove_anonymous(@embargo_collection) + remove_embargo_collection(@embargo_collection) + { message: "Embargo #{@embargo_collection.label} has been deleted" } + end + rescue StandardError => e + { error: e.message } + end + end + + post :refresh do + @id = params[:id] + refresh_embargo_metadata(params[:collection_id]) + end + + post :move do + begin + # @new_embargo = params[:new_embargo] + @element = params[:element] + @new_embargo_collection = fetch_embargo_collection(params[:new_embargo]&.to_i, current_user) if params[:new_embargo].present? && params[:new_embargo]&.to_i >= 0 + case @element['type'] + when 'Sample' + CollectionsSample + when 'Reaction' + CollectionsReaction + end.remove_in_collection(@element['id'], [@embargo_collection.id]) + + case @element['type'] + when 'Sample' + CollectionsSample + when 'Reaction' + CollectionsReaction + end.create_in_collection(@element['id'], [@new_embargo_collection.id]) + + { col_id: @embargo_collection.id, + new_embargo: @new_embargo_collection.publication, + is_new_embargo: params[:new_embargo]&.to_i == 0, + message: "#{@element['type']} [#{@element['title']}] has been moved from Embargo Bundle [#{@embargo_collection.label}] to Embargo Bundle [#{@new_embargo_collection.label}]" } + rescue StandardError => e + { error: e.message } + end + end + end + end + end +end diff --git a/app/api/chemotion/sample_api.rb b/app/api/chemotion/sample_api.rb index 2dbab6073..88a461d36 100644 --- a/app/api/chemotion/sample_api.rb +++ b/app/api/chemotion/sample_api.rb @@ -223,7 +223,7 @@ class SampleAPI < Grape::API end end else - scope = scope.order('updated_at DESC') + scope = scope.order('updated_at DESC').distinct paginate(scope).each do |sample| next if sample.nil? serialized_sample = sample_serializer_selector.call(sample) diff --git a/app/api/chemotion/search_api.rb b/app/api/chemotion/search_api.rb index 87245f2e8..441660c51 100644 --- a/app/api/chemotion/search_api.rb +++ b/app/api/chemotion/search_api.rb @@ -4,6 +4,7 @@ class SearchAPI < Grape::API # TODO implement search cache? helpers CollectionHelpers + helpers CompoundHelpers helpers do params :search_params do optional :page, type: Integer @@ -16,7 +17,7 @@ class SearchAPI < Grape::API # polymer_type #] optional :elementType, type: String, values: %w[ - All Samples Reactions Wellplates Screens all samples reactions wellplates screens elements + All Samples Reactions Wellplates Screens all samples reactions wellplates screens elements embargo ] optional :molfile, type: String optional :search_type, type: String, values: %w[similar sub] @@ -226,41 +227,140 @@ def serialize_samples samples, page, search_method, molecule_sort end def serialization_by_elements_and_page(elements, page = 1, molecule_sort = false) - samples = elements.fetch(:samples, []) - reactions = elements.fetch(:reactions, []) - wellplates = elements.fetch(:wellplates, []) - screens = elements.fetch(:screens, []) + samples = elements.fetch(:samples, [0]) + reactions = elements.fetch(:reactions, [0]) + wellplates = elements.fetch(:wellplates, [0]) + screens = elements.fetch(:screens, [0]) + + if params[:is_public] + com_config = Rails.configuration.compound_opendata + sample_join = <<~SQL + INNER JOIN ( + SELECT molecule_id, published_at max_published_at, sample_svg_file, id as sid + FROM ( + SELECT samples.*, pub.published_at, rank() OVER (PARTITION BY molecule_id order by pub.published_at desc) as rownum + FROM samples, publications pub + WHERE pub.element_type='Sample' and pub.element_id=samples.id and pub.deleted_at ISNULL + and samples.id IN (#{samples.join(',')})) s where rownum = 1 + ) s on s.molecule_id = molecules.id + SQL + + embargo_sql = <<~SQL + molecules.*, sample_svg_file, sid, + (select count(*) from publication_ontologies po where po.element_type = 'Sample' and po.element_id = sid) as ana_cnt, + (select "collections".label from "collections" inner join collections_samples cs on collections.id = cs.collection_id + and cs.sample_id = sid where "collections"."deleted_at" is null and (ancestry in ( + select c.id::text from collections c where c.label = 'Published Elements')) order by position asc limit 1) as embargo, + (select id from publications where element_type = 'Sample' and element_id = sid and deleted_at is null) as pub_id, + (select to_char(published_at, 'DD-MM-YYYY') from publications where element_type = 'Sample' and element_id = sid and deleted_at is null) as published_at, + (select taggable_data -> 'creators'->0->>'name' from publications where element_type = 'Sample' and element_id = sid and deleted_at is null) as author_name + SQL + + ttl_mol = Molecule.joins(sample_join).order("s.max_published_at desc").select(embargo_sql) + slist = paginate(ttl_mol) + sentities = Entities::MoleculePublicationListEntity.represent(slist, serializable: true) + ssids = sentities.map { |e| e[:sid] } + + xvial_count_ssql = <<~SQL + inner join element_tags e on e.taggable_id = samples.id and (e.taggable_data -> 'xvial' is not null and e.taggable_data -> 'xvial' ->> 'num' != '') + SQL + x_cnt_sids = Sample.joins(xvial_count_ssql).where(id: ssids).distinct.pluck(:id) || [] + + xvial_com_ssql = <<~SQL + inner join molecules m on m.id = samples.molecule_id + inner join com_xvial(true) a on a.x_inchikey = m.inchikey + SQL + x_com_sids = Sample.joins(xvial_com_ssql).where(id: ssids).distinct.pluck(:id) if com_config.present? && com_config.allowed_uids.include?(current_user&.id) + + sentities = sentities.each do |obj| + obj[:xvial_count] = 1 if x_cnt_sids.include?(obj[:sid]) + obj[:xvial_com] = 1 if com_config.present? && com_config.allowed_uids.include?(current_user&.id) && (x_com_sids || []).include?(obj[:sid]) + obj[:xvial_archive] = get_xdata(obj[:inchikey], obj[:sid], true) + end + + filter_reactions = Reaction.where("reactions.id in (?)", reactions) + + embargo_rsql = <<~SQL + reactions.id, reactions.name, reactions.reaction_svg_file, publications.id as pub_id, to_char(publications.published_at, 'DD-MM-YYYY') as published_at, publications.taggable_data, + (select count(*) from publication_ontologies po where po.element_type = 'Reaction' and po.element_id = reactions.id) as ana_cnt, + (select "collections".label from "collections" inner join collections_reactions cr on collections.id = cr.collection_id + and cr.reaction_id = reactions.id where "collections"."deleted_at" is null and (ancestry in ( + select c.id::text from collections c where c.label = 'Published Elements')) order by position asc limit 1) as embargo + SQL + + reaction_list = paginate(filter_reactions.joins(:publication).select(embargo_rsql).order('publications.published_at desc')) + reaction_entities = Entities::ReactionPublicationListEntity.represent(reaction_list, serializable: true) + reaction_ids = reaction_entities.map { |e| e[:id] } + + + xvial_count_sql = <<~SQL + inner join element_tags e on e.taggable_id = reactions_samples.sample_id and (e.taggable_data -> 'xvial' is not null and e.taggable_data -> 'xvial' ->> 'num' != '') + SQL + reaction_x_cnt_ids = ReactionsSample.joins(xvial_count_sql).where(type: 'ReactionsProductSample', reaction_id: reaction_ids).distinct.pluck(:reaction_id) || [] + + xvial_com_sql = <<~SQL + inner join samples s on reactions_samples.sample_id = s.id and s.deleted_at is null + inner join molecules m on m.id = s.molecule_id + inner join com_xvial(true) a on a.x_inchikey = m.inchikey + SQL + reaction_x_com_ids = ReactionsSample.joins(xvial_com_sql).where(type: 'ReactionsProductSample', reaction_id: reaction_ids).distinct.pluck(:reaction_id) if com_config.present? && com_config.allowed_uids.include?(current_user&.id) + + reaction_entities = reaction_entities.each do |obj| + obj[:xvial_count] = 1 if reaction_x_cnt_ids.include?(obj[:id]) + obj[:xvial_com] = 1 if com_config.present? && com_config.allowed_uids.include?(current_user&.id) && (reaction_x_com_ids || []).include?(obj[:id]) + end + + + return { + publicMolecules: { + molecules: sentities, + totalElements: ttl_mol.size, + page: page, + perPage: page_size, + ids: ssids + }, + publicReactions: { + reactions: reaction_entities, + totalElements: reactions.size, + page: page, + perPage: page_size, + ids: reaction_ids + } + } + end + samples_data = serialize_samples(samples, page, search_by_method, molecule_sort) serialized_samples = samples_data[:data] samples_size = samples_data[:size] + samples.delete(0) ids = Kaminari.paginate_array(reactions).page(page).per(page_size) serialized_reactions = Reaction.includes( - :literatures, :tag, + :tag, reactions_starting_material_samples: :sample, - reactions_solvent_samples: :sample, - reactions_reactant_samples: :sample, - reactions_product_samples: :sample, - container: :attachments + reactions_product_samples: :sample ).find(ids).map {|s| ReactionSerializer.new(s).serializable_hash.deep_symbolize_keys } + reactions.delete(0) ids = Kaminari.paginate_array(wellplates).page(page).per(page_size) - klass = "WellplateListSerializer::Level#{@dl_wp}".constantize + klass = "WellplateListSerializer::Level#{@dl_wp || 0}".constantize serialized_wellplates = Wellplate.includes( collections: :sync_collections_users, wells: :sample - ).find(ids).map{ |s| + ).find_by(id: ids)&.map{ |s| klass.new(s,1).serializable_hash.deep_symbolize_keys } + wellplates.delete(0) ids = Kaminari.paginate_array(screens).page(page).per(page_size) serialized_screens = Screen.includes( collections: :sync_collections_users - ).find(ids).map{ |s| + ).find_by(id: ids)&.map{ |s| ScreenSerializer.new(s).serializable_hash.deep_symbolize_keys } + screens.delete(0) result = { samples: { @@ -319,9 +419,12 @@ def serialization_by_elements_and_page(elements, page = 1, molecule_sort = false def search_elements(c_id = @c_id, dl = @dl) search_method = search_by_method molecule_sort = params[:molecule_sort] - arg = params[:selection][:name] + arg = params[:selection] && params[:selection][:name] return if !(search_method =~ /advanced|structure/) && !arg.presence dl_s = dl[:sample_detail_level] || 0 + + search_method = 'chemotion_id' if arg&.match(/(CRR|CRS|CRD)-\d+/) + scope = case search_method when 'polymer_type' if dl_s > 0 @@ -366,26 +469,62 @@ def search_elements(c_id = @c_id, dl = @dl) advanced_search(c_id) when 'elements' elements_search(c_id) + when 'chemotion_id' + if arg.match(/(CRR|CRS|CRD)-\d+/) && arg.split('-').length == 2 + case arg.split('-')[0] + when 'CRS' + Sample.by_collection_id(c_id).joins(:publication).where('publications.id = ?', "#{arg.split('-')[1]}") + when 'CRR' + Reaction.by_collection_id(c_id).joins(:publication).where('publications.id = ?', "#{arg.split('-')[1]}") + when 'CRD' + begin + parent_node = Publication.find(arg.split('-')[1])&.parent + parent_node && parent_node.element.class.by_collection_id(c_id).joins(:publication).where('publications.id = ?', "#{parent_node.id}") + rescue => e + Sample.none + end + end + else + end end - if search_method == 'advanced' && molecule_sort == false - arg_value_str = adv_params.first['value'].split(/(\r)?\n|,/).map(&:strip) - .select{ |s| !s.empty? }.join(',') - return scope.order( - "position(','||(#{adv_params.first['field']['column']}::text)||',' in ','||(#{ActiveRecord::Base.connection.quote(arg_value_str)}::text)||',')" - ) - elsif search_method == 'advanced' && molecule_sort == true - return scope.order('samples.updated_at DESC') - elsif search_method != 'advanced' && molecule_sort == true - return scope.includes(:molecule) - .joins(:molecule) - .order( - "LENGTH(SUBSTRING(molecules.sum_formular, 'C\\d+'))" - ).order('molecules.sum_formular') - elsif search_by_method.start_with?("element_short_label_") - klass = ElementKlass.find_by(name: search_by_method.sub("element_short_label_","")) - return Element.by_collection_id(c_id).by_klass_id_short_label(klass.id, arg) + if ((c_id = Collection.public_collection_id) && + (params[:selection] && params[:selection][:authors_params] && params[:selection][:authors_params][:type] && params[:selection][:authors_params][:value] && params[:selection][:authors_params][:value].length > 0)) + if params[:selection][:authors_params][:type] == 'Authors' + author_sql = ActiveRecord::Base.send(:sanitize_sql_array, [" author_id in (?)", params[:selection][:authors_params][:value].join("','")]) + + adv_search = <<~SQL + INNER JOIN publication_authors pub on pub.element_id = samples.id and pub.element_type = 'Sample' and pub.state = 'completed' + and #{author_sql} + SQL + elsif params[:selection][:authors_params][:type] == 'Contributors' + contributor_sql = ActiveRecord::Base.send(:sanitize_sql_array, [" published_by in (?)", params[:selection][:authors_params][:value].join("','")]) + adv_search = <<~SQL + INNER JOIN publications pub on pub.element_id = samples.id and pub.element_type = 'Sample' and pub.state = 'completed' + and #{contributor_sql} + SQL + end + end + + if adv_params && adv_params.length > 0 + if search_method == 'advanced' && molecule_sort == false + arg_value_str = adv_params.first['value'].split(/(\r)?\n|,/).map(&:strip) + .select{ |s| !s.empty? }.join(',') + return scope.order( + "position(','||(#{adv_params.first['field']['column']}::text)||',' in ','||(#{ActiveRecord::Base.connection.quote(arg_value_str)}::text)||',')" + ) + elsif search_method == 'advanced' && molecule_sort == true + return scope.order('samples.updated_at DESC') + elsif search_method != 'advanced' && molecule_sort == true + return scope.includes(:molecule) + .joins(:molecule) + .order( + "LENGTH(SUBSTRING(molecules.sum_formular, 'C\\d+'))" + ).order('molecules.sum_formular') + end end + + return scope.joins(adv_search) unless adv_search.nil? return scope end @@ -395,51 +534,49 @@ def elements_by_scope(scope, collection_id = @c_id) .includes(molecule: :tag) user_reactions = Reaction.by_collection_id(collection_id).includes( :literatures, :tag, - reactions_starting_material_samples: :sample, - reactions_solvent_samples: :sample, - reactions_reactant_samples: :sample, + # reactions_starting_material_samples: :sample, + # reactions_solvent_samples: :sample, + # reactions_reactant_samples: :sample, reactions_product_samples: :sample, ) - user_wellplates = Wellplate.by_collection_id(collection_id).includes( - wells: :sample - ) - user_screens = Screen.by_collection_id(collection_id) - - user_elements = Element.by_collection_id(collection_id) + # user_wellplates = Wellplate.by_collection_id(collection_id).includes( + # wells: :sample + # ) + # user_screens = Screen.by_collection_id(collection_id) case scope&.first when Sample elements[:samples] = scope&.pluck(:id) elements[:reactions] = ( user_reactions.by_sample_ids(scope&.map(&:id)).pluck(:id) ).uniq - elements[:wellplates] = user_wellplates.by_sample_ids(scope&.map(&:id)).uniq.pluck(:id) - elements[:screens] = user_screens.by_wellplate_ids(elements[:wellplates]).pluck(:id) - elements[:elements] = ( - user_elements.by_sample_ids(scope&.map(&:id)).pluck(:id) - ).uniq + # elements[:wellplates] = user_wellplates.by_sample_ids(scope&.map(&:id)).uniq.pluck(:id) + # elements[:screens] = user_screens.by_wellplate_ids(elements[:wellplates]).pluck(:id) + # elements[:elements] = ( + # user_elements.by_sample_ids(scope&.map(&:id)).pluck(:id) + # ).uniq when Reaction elements[:reactions] = scope&.pluck(:id) elements[:samples] = user_samples.by_reaction_ids(scope&.map(&:id)).pluck(:id).uniq - elements[:wellplates] = user_wellplates.by_sample_ids(elements[:samples]).uniq.pluck(:id) - elements[:screens] = user_screens.by_wellplate_ids(elements[:wellplates]).pluck(:id) - when Wellplate - elements[:wellplates] = scope&.pluck(:id) - elements[:screens] = user_screens.by_wellplate_ids(elements[:wellplates]).uniq.pluck(:id) - elements[:samples] = user_samples.by_wellplate_ids(elements[:wellplates]).uniq.pluck(:id) - elements[:reactions] = ( - user_reactions.by_sample_ids(elements[:samples]).pluck(:id) - ).uniq - when Screen - elements[:screens] = scope&.pluck(:id) - elements[:wellplates] = user_wellplates.by_screen_ids(scope).uniq.pluck(:id) - elements[:samples] = user_samples.by_wellplate_ids(elements[:wellplates]).uniq.pluck(:id) - elements[:reactions] = ( - user_reactions.by_sample_ids(elements[:samples]).pluck(:id) - ).uniq.pluck(:id) - when Element - elements[:elements] = scope&.pluck(:id) - sids = ElementsSample.where(element_id: elements[:elements]).pluck :sample_id - elements[:samples] = Sample.by_collection_id(collection_id).where(id: sids).uniq.pluck(:id) + # elements[:wellplates] = user_wellplates.by_sample_ids(elements[:samples]).uniq.pluck(:id) + # elements[:screens] = user_screens.by_wellplate_ids(elements[:wellplates]).pluck(:id) + # when Wellplate + # elements[:wellplates] = scope&.pluck(:id) + # elements[:screens] = user_screens.by_wellplate_ids(elements[:wellplates]).uniq.pluck(:id) + # elements[:samples] = user_samples.by_wellplate_ids(elements[:wellplates]).uniq.pluck(:id) + # elements[:reactions] = ( + # user_reactions.by_sample_ids(elements[:samples]).pluck(:id) + # ).uniq + # when Screen + # elements[:screens] = scope&.pluck(:id) + # elements[:wellplates] = user_wellplates.by_screen_ids(scope).uniq.pluck(:id) + # elements[:samples] = user_samples.by_wellplate_ids(elements[:wellplates]).uniq.pluck(:id) + # elements[:reactions] = ( + # user_reactions.by_sample_ids(elements[:samples]).pluck(:id) + # ).uniq.pluck(:id) + # when Element + # elements[:elements] = scope&.pluck(:id) + # sids = ElementsSample.where(element_id: elements[:elements]).pluck :sample_id + # elements[:samples] = Sample.by_collection_id(collection_id).where(id: sids).uniq.pluck(:id) when AllElementSearch::Results # TODO check this samples_ids + molecules_ids ???? elements[:samples] = (scope&.samples_ids + scope&.molecules_ids) @@ -448,16 +585,16 @@ def elements_by_scope(scope, collection_id = @c_id) user_reactions.by_sample_ids(elements[:samples]).pluck(:id) ).uniq - elements[:wellplates] = ( - scope&.wellplates_ids + - user_wellplates.by_sample_ids(elements[:samples]).pluck(:id) - ).uniq + # elements[:wellplates] = ( + # scope&.wellplates_ids + + # user_wellplates.by_sample_ids(elements[:samples]).pluck(:id) + # ).uniq - elements[:screens] = ( - scope&.screens_ids + - user_screens.by_wellplate_ids(elements[:wellplates]).pluck(:id) - ).uniq - elements[:elements] = (scope&.element_ids).uniq + # elements[:screens] = ( + # scope&.screens_ids + + # user_screens.by_wellplate_ids(elements[:wellplates]).pluck(:id) + # ).uniq + # elements[:elements] = (scope&.element_ids).uniq end elements end @@ -487,6 +624,11 @@ def elements_by_scope(scope, collection_id = @c_id) end end + after_validation do + check_params_collection_id + set_var_for_unsigned_user unless current_user + end + namespace :all do desc "Return all matched elements and associations for substring query" params do @@ -501,7 +643,6 @@ def elements_by_scope(scope, collection_id = @c_id) scope = search_elements(@c_id, @dl) return unless scope elements_ids = elements_by_scope(scope) - serialization_by_elements_and_page( elements_ids, params[:page], @@ -624,6 +765,30 @@ def elements_by_scope(scope, collection_id = @c_id) ) end end + + namespace :embargo do + desc "Return samples and reactions by embargo" + params do + use :search_params + end + post do + col_id = Collection.find_by(label: params[:selection][:name], is_synchronized: true)&.id + + return serialization_by_elements_and_page({}, params[:page], params[:molecule_sort]) unless col_id.present? + + scope = Sample.by_collection_id(col_id).where.not(short_label: %w[solvent reactant]) + return serialization_by_elements_and_page({}, params[:page], params[:molecule_sort]) unless scope + + return serialization_by_elements_and_page({}, params[:page], params[:molecule_sort]) unless ElementsPolicy.new(current_user, scope).read? + + elements_ids = elements_by_scope(scope, col_id) + serialization_by_elements_and_page( + elements_ids, + params[:page], + params[:molecule_sort] + ) + end + end end end end diff --git a/app/api/chemotion/segment_api.rb b/app/api/chemotion/segment_api.rb index 9a84c4453..aa1f8b953 100644 --- a/app/api/chemotion/segment_api.rb +++ b/app/api/chemotion/segment_api.rb @@ -1,6 +1,7 @@ module Chemotion class SegmentAPI < Grape::API include Grape::Kaminari + helpers GenericHelpers resource :segments do namespace :klasses do @@ -14,6 +15,85 @@ class SegmentAPI < Grape::API present list.sort_by(&:place), with: Entities::SegmentKlassEntity, root: 'klass' end end + + namespace :list_segment_klass do + desc 'list Generic Segment Klass' + params do + optional :is_active, type: Boolean, desc: 'Active or Inactive Segment' + end + get do + list = SegmentKlass.where(is_active: params[:is_active]) if params[:is_active].present? + list = SegmentKlass.all unless params[:is_active].present? + present list.sort_by(&:place), with: Entities::SegmentKlassEntity, root: 'klass' + end + end + + namespace :create_segment_klass do + desc 'create Generic Segment Klass' + params do + requires :label, type: String, desc: 'Segment Klass Label' + requires :element_klass, type: Integer, desc: 'Element Klass Id' + optional :desc, type: String, desc: 'Segment Klass Desc' + optional :place, type: String, desc: 'Segment Klass Place', default: '100' + optional :properties_template, type: Hash, desc: 'Element Klass properties template' + end + after_validation do + authenticate_admin!('segments') + @klass = fetch_klass('ElementKlass', params[:element_klass]) + end + post do + place = params[:place] + begin + place = place.to_i if place.present? && place.to_i == place.to_f + rescue StandardError + place = 100 + end + + uuid = SecureRandom.uuid + template = { uuid: uuid, layers: {}, select_options: {} } + attributes = declared(params, include_missing: false) + attributes[:properties_template]['uuid'] = uuid if attributes[:properties_template].present? + template = attributes[:properties_template].present? ? attributes[:properties_template] : template + template['eln'] = Chemotion::Application.config.version + template['klass'] = 'SegmentKlass' + attributes.merge!(properties_template: template, element_klass: @klass, created_by: current_user.id, place: place) + attributes[:uuid] = uuid + attributes[:released_at] = DateTime.now + attributes[:properties_release] = attributes[:properties_template] + klass = SegmentKlass.create!(attributes) + klass.reload + klass.create_klasses_revision(current_user.id) + rescue ActiveRecord::RecordInvalid => e + { error: e.message } + end + end + + namespace :update_segment_klass do + desc 'update Generic Segment Klass' + params do + requires :id, type: Integer, desc: 'Segment Klass ID' + optional :label, type: String, desc: 'Segment Klass Label' + optional :desc, type: String, desc: 'Segment Klass Desc' + optional :place, type: String, desc: 'Segment Klass Place', default: '100' + optional :identifier, type: String, desc: 'Segment Identifier' + end + after_validation do + authenticate_admin!('segments') + @segment = fetch_klass('SegmentKlass', params[:id]) + end + post do + place = params[:place] + begin + place = place.to_i if place.present? && place.to_i == place.to_f + rescue StandardError + place = 100 + end + attributes = declared(params, include_missing: false) + attributes.delete(:id) + attributes[:place] = place + @segment&.update!(attributes) + end + end end end end diff --git a/app/api/chemotion/suggestion_api.rb b/app/api/chemotion/suggestion_api.rb index 405632565..db50b0ce1 100644 --- a/app/api/chemotion/suggestion_api.rb +++ b/app/api/chemotion/suggestion_api.rb @@ -122,7 +122,26 @@ def search_possibilities_by_type_user_and_collection(type) requirements: requirements } else - element_short_label = dl_e.positive? && search_by_element_short_label.call(Element, qry) || [] + # element_short_label = dl_e.positive? && search_by_element_short_label.call(Element, qry) || [] + if qry =~ /\ACR(R|S|D)-(\d+)\Z/ + typ = Regexp.last_match(1) + pid = Regexp.last_match(2) + element_type = case typ + when 'R' + 'Reaction' + when 'S' + 'Sample' + when 'D' + 'Container' + end + pids = Publication.where( + "state LIKE 'completed%' and element_type = ? and id = ?", element_type, pid + ).map do |pub| + "CR#{typ}-#{pub.id}" + end + end + chemotion_id = pids || [] + element_short_label = dl_e&.positive? && search_by_element_short_label.call(Element, qry) || [] sample_name = dl_s.positive? && search_by_field.call(Sample, :name, qry) || [] sample_short_label = dl_s.positive? && search_by_field.call(Sample, :short_label, qry) || [] sample_external_label = dl_s > -1 && search_by_field.call(Sample, :external_label, qry) || [] @@ -145,6 +164,7 @@ def search_possibilities_by_type_user_and_collection(type) { element_short_label: element_short_label, + chemotion_id: chemotion_id, sample_name: sample_name, sample_short_label: sample_short_label, sample_external_label: sample_external_label, @@ -169,6 +189,8 @@ def search_possibilities_by_type_user_and_collection(type) resource :suggestions do after_validation do + check_params_collection_id + set_var_for_unsigned_user unless current_user set_var end @@ -179,8 +201,14 @@ def search_possibilities_by_type_user_and_collection(type) end get do params[:element_type] - search_possibilities = search_possibilities_by_type_user_and_collection(params[:element_type]) - { suggestions: search_possibilities_to_suggestions(search_possibilities) } + if params[:element_type] == 'embargo' + cols = Collection.all_embargos(current_user.id).where("label like '#{params[:query]}%'").order(:label) + suggestions = cols.map { |col| { name: col.label, search_by_method: 'embargo' } } + { suggestions: suggestions } + else + search_possibilities = search_possibilities_by_type_user_and_collection(params[:element_type]) + { suggestions: search_possibilities_to_suggestions(search_possibilities) } + end end end end diff --git a/app/api/chemotion/sync_collection_api.rb b/app/api/chemotion/sync_collection_api.rb index b2f2709d7..e5c0c8595 100644 --- a/app/api/chemotion/sync_collection_api.rb +++ b/app/api/chemotion/sync_collection_api.rb @@ -19,6 +19,24 @@ class SyncCollectionAPI < Grape::API end end + namespace :publication do + desc "Return the 'All' collection of the current user" + get do + current_user.sync_published_collection + end + end + + namespace :review do + desc "Return the 'All' collection of the current user" + get do + if User.reviewer_ids.include?(current_user.id) + current_user.sync_element_to_review_collection + else + current_user.sync_reviewing_collection + end + end + end + namespace :take_ownership do desc 'Take ownership of collection with specified sync_collections_user id' params do @@ -38,10 +56,29 @@ class SyncCollectionAPI < Grape::API get_child = proc do |children, collections| children.each do |obj| child = collections.select { |dt| dt['ancestry'] == obj['id'].to_s } + get_child.call(child, collections) if child.count.positive? obj[:children] = child if child.count.positive? end end + handle_review = proc do |collections| + cols = [] + collections.each do |col| + unless col['label'] == 'Reviewing' || col['label'] == 'Pending Publications' || col['label'] == 'Element To Review' || col['label'] == 'Reviewed' + cols.push(col) + next + end + oc = SyncCollectionsUser.find(col['id'])&.collection + sc = (oc&.samples&.joins(:publication)&.where('publications.ancestry is null') || []).length + rc = (oc&.reactions&.joins(:publication)&.where('publications.ancestry is null') || []).length + next if (sc + rc).zero? + + col['label'] = col['label'] + ",S#{sc},R#{rc}" if col['label'] == 'Reviewing' || col['label'] == 'Element To Review' || col['label'] == 'Reviewed' + cols.push(col) + end + cols + end + desc 'Return all remote serialized collections' get :sync_remote_roots do collections = Collection.joins(:sync_collections_users) @@ -58,6 +95,7 @@ class SyncCollectionAPI < Grape::API SQL ).as_json root_ancestries = [] + collections = handle_review.call(collections) collections.each do |obj| root_ancestries.push(obj['ancestry']) end diff --git a/app/api/chemotion/ui_api.rb b/app/api/chemotion/ui_api.rb index bf1b5faeb..fefffead6 100644 --- a/app/api/chemotion/ui_api.rb +++ b/app/api/chemotion/ui_api.rb @@ -9,11 +9,15 @@ class UiAPI < Grape::API params do end get 'initialize' do - sconfig = Rails.configuration.try(:spectra).try(:url) + has_chem_spectra = Rails.configuration.try(:spectra).try(:chemspectra).try(:url).present? + has_nmrium_wrapper = Rails.configuration.try(:spectra).try(:nmriumwrapper).try(:url).present? m_config = Rails.root.join('config', 'matrices.json') sfn_config = Rails.configuration.try(:sfn_config).try(:provider) + converter_config = Rails.configuration.try(:converter).try(:url) + radar_config = Rails.configuration.try(:radar).try(:url) { - has_chem_spectra: sconfig.present?, + has_chem_spectra: has_chem_spectra, + has_nmrium_wrapper: has_nmrium_wrapper, matrices: File.exist?(m_config) ? JSON.parse(File.read(m_config)) : {}, klasses: ElementKlass.where(is_active: true, is_generic: true)&.pluck(:name) || [], structure_editors: Rails.configuration.structure_editors, diff --git a/app/api/chemotion/user_api.rb b/app/api/chemotion/user_api.rb index 5c6ab9485..c7992b530 100644 --- a/app/api/chemotion/user_api.rb +++ b/app/api/chemotion/user_api.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + module Chemotion class UserAPI < Grape::API resource :users do @@ -7,9 +8,16 @@ class UserAPI < Grape::API requires :name, type: String end get 'name' do - unless params[:name].nil? || params[:name].empty? - { users: User.where(type: %w[Person Group]).by_name(params[:name]).limit(3) - .select('first_name', 'last_name', 'name', 'id', 'name_abbreviation', 'name_abbreviation as abb', 'type as user_type') } + if params[:name].present? && params[:name].gsub(/\s/, '').size > 3 + { + users: User.where(type: %w[Person Group]).where.not(confirmed_at: nil) + .by_name(params[:name]).limit(3).joins(:affiliations) + .select( + 'first_name', 'last_name', 'name', 'id', 'name_abbreviation', + 'name_abbreviation as abb', 'type as user_type', + 'jsonb_object_agg(affiliations.id, affiliations.department || chr(44)|| chr(32) || affiliations.organization || chr(44)|| chr(32) || affiliations.country) as aff' + ).group('first_name', 'last_name', 'name', 'id', 'name_abbreviation') + } else { users: [] } end @@ -94,6 +102,120 @@ class UserAPI < Grape::API end end + resource :collaborators do + namespace :list do + desc 'fetch collaborators of current user' + get do + ids = UsersCollaborator.where(user_id: current_user.id).pluck(:collaborator_id) + data = User.where(id: ids) + present data, with: Entities::CollaboratorEntity, root: 'authors' + end + end + namespace :user do + desc 'fetch collaborators of current user' + params do + optional :name, type: String + optional :first, type: String + end + get do + sql_str = [] + if params[:name].present? && params[:first].present? + sql_str = [" LOWER(last_name) like ? and LOWER(first_name) like ? ",'%'+params[:name].downcase+'%','%'+params[:first].downcase+'%'] + end + if !params[:name].present? && params[:first].present? + sql_str = [" LOWER(first_name) LIKE ? ",'%'+params[:first].downcase+'%'] + end + if params[:name].present? && !params[:first].present? + sql_str = [" LOWER(last_name) LIKE ? ",'%'+params[:name].downcase+'%'] + end + + data = Person.where.not(confirmed_at: nil).where(sql_str) + present data, with: Entities::CollaboratorEntity, root: 'users' + end + end + namespace :add do + desc 'add user to my collabration' + params do + requires :id, type: Integer + end + post do + new_author = UsersCollaborator.create({ user_id: current_user.id, collaborator_id: params[:id] }) + user = User.find(params[:id]) + present user, with: Entities::CollaboratorEntity, root: 'user' + end + end + namespace :add_aff do + desc 'add user to my collabration' + params do + requires :id, type: Integer + requires :department, type: String + requires :organization, type: String + requires :country, type: String + end + post do + collaborator = User.find(params[:id]) + aff = [Affiliation.find_or_create_by(country: params[:country], + organization: params[:organization], department: params[:department])] + collaborator.affiliations << aff unless aff.nil? + present collaborator, with: Entities::CollaboratorEntity, root: 'user' + end + end + namespace :delete do + desc 'remove user from my collabration' + params do + requires :id, type: Integer + end + post do + uc = UsersCollaborator.find_by(user_id: current_user.id, collaborator_id: params[:id]) + uc.delete + #present user, with: Entities::CollaboratorEntity, root: 'user' + end + end + namespace :delete_aff do + desc 'remove affilication from my collabration' + params do + requires :user_id, type: Integer + requires :aff_id, type: Integer + end + post do + + ua = UserAffiliation.find_by(user_id: params[:user_id], affiliation_id: params[:aff_id]) + ua.destroy! + + user = User.find(params[:user_id]) + present user, with: Entities::CollaboratorEntity, root: 'user' + end + end + namespace :create do + desc 'create and add user to my collabration' + params do + requires :lastName, type: String + requires :firstName, type: String + optional :email, type: String + requires :department, type: String + requires :organization, type: String + requires :country, type: String + end + post do + attributes = {} #declared(params, include_missing: false) + attributes[:first_name] = params[:firstName] + attributes[:last_name] = params[:lastName] + attributes[:type] = 'Collaborator' + attributes[:confirmed_at] = DateTime.now + attributes[:name_abbreviation] = "c#{SecureRandom.random_number(9999)}" + attributes[:password] = Devise.friendly_token.first(8) + attributes[:email] = "#{current_user.name_abbreviation}.#{attributes[:name_abbreviation]}@chemotion.net" + new_user = User.create!(attributes) + new_user.profile.update!({data: {}}) + new_user.affiliations = [Affiliation.find_or_create_by(country: params[:country], + organization: params[:organization], department: params[:department])] + + new_author = UsersCollaborator.create({ user_id: current_user.id, collaborator_id: new_user.id }) + present new_user, with: Entities::CollaboratorEntity, root: 'user' + end + end + end + resource :groups do rescue_from ActiveRecord::RecordInvalid do |error| message = error.record.errors.messages.map do |attr, msg| diff --git a/app/api/entities/collaborator_entity.rb b/app/api/entities/collaborator_entity.rb new file mode 100644 index 000000000..f548f1272 --- /dev/null +++ b/app/api/entities/collaborator_entity.rb @@ -0,0 +1,12 @@ +module Entities + class CollaboratorEntity < Grape::Entity + expose :id, :name, :initials, :email, :type, :first_name, :last_name + expose :affiliations + expose :orcid + expose :current_affiliations + + def orcid + object.orcid + end + end +end diff --git a/app/api/entities/collection_sync_entity.rb b/app/api/entities/collection_sync_entity.rb index b4480887d..1ec2e0eb4 100644 --- a/app/api/entities/collection_sync_entity.rb +++ b/app/api/entities/collection_sync_entity.rb @@ -45,6 +45,9 @@ class CollectionSyncEntity < Grape::Entity expose :sharer do |obj| obj['temp_sharer'] end + expose :is_public do |obj| + obj['shared_by'] && obj['shared_by']['initials'] == 'CI' + end expose :children, as: 'children', using: Entities::CollectionSyncEntity expose :is_sync_to_me do |obj| diff --git a/app/api/entities/container_entity.rb b/app/api/entities/container_entity.rb index 8a87e6790..2c8eeb4d8 100644 --- a/app/api/entities/container_entity.rb +++ b/app/api/entities/container_entity.rb @@ -1,57 +1,97 @@ module Entities class ContainerEntity < Grape::Entity expose :big_tree, merge: true + expose :dataset_doi + # expose :doi, if: -> (obj, opts) { obj.respond_to? :doi} + + def dataset_doi + object.full_doi + end + + def pub_id + object.publication&.id + end def big_tree(container = object) - dataset_ids = {} + @dataset_ids = {} bt = container.attributes.slice('id', 'container_type', 'name') bt['children'] = container.hash_tree[container].map do |c1, c2s| as = c1.attributes.slice('id', 'container_type', 'name') ## mapping analysis element as['children'] = c2s.map do |c2, c3s| - a = c2.attributes.slice('id', 'container_type', 'name', 'description') - a['extended_metadata'] = get_extended_metadata(c2) - dids = [] - ## mapping datasets - a['children'] = c3s.map do |c3, _| - ds = c3.attributes.slice('id', 'container_type', 'name', 'description') - dids << ds['id'] - ds['extended_metadata'] = get_extended_metadata(c3) - ds + if c2['container_type'] == 'link' + get_link(c2) + else + get_analysis(c2, c3s) end - dataset_ids[a['id']] = dids - a['preview_img'] = dids - a end as end - attachments = Attachment.where_container(dataset_ids.values.flatten).to_a - code_logs = CodeLog.where(source_id: dataset_ids.keys, source: 'container').to_a + attachments = Attachment.where_container(@dataset_ids.values.flatten).to_a + code_logs = CodeLog.where(source_id: @dataset_ids.keys, source: 'container').to_a - bt.dig('children', 0, 'children')&.each do |analysis| - analysis['preview_img'] = preview_img(dataset_ids[analysis['id']], attachments) - analysis['code_log'] = code_logs.find { |cl| cl.source_id == analysis['id'] }.attributes - analysis['children'].each do |ds_entity| - atts = attachments.select { |a| a.attachable_id == ds_entity['id'] } - ds_entity['attachments'] = Entities::AttachmentEntity.represent(atts) - gds = Dataset.find_by(element_type: 'Container', element_id: ds_entity['id']) - ds_entity['dataset'] = Entities::DatasetEntity.represent(gds) if gds.present? - end + bt.dig('children', 0, 'children')&.each do |container| + update_analysis(container, attachments, code_logs) end bt end private + def get_link(container) + target_container = Container.find(container.extended_metadata['target_id']) + target_children = target_container.hash_tree[target_container] + link = get_analysis(target_container, target_children) + link['link_id'] = container.id + link + end + + def get_analysis(container, children) + analysis = container.attributes.slice('id', 'container_type', 'name', 'description') + analysis['dataset_doi'] = container.full_doi if container.respond_to? :full_doi + analysis['pub_id'] = container.publication&.id if container.respond_to? :publication + analysis['extended_metadata'] = get_extended_metadata(container) + dids = [] + ## mapping datasets + analysis['children'] = children.map do |child, _| + ds = child.attributes.slice('id', 'container_type', 'name', 'description') + ds['dataset_doi'] = child.full_doi if child.respond_to? :full_doi + ds['pub_id'] = child.publication&.id if child.respond_to? :publication + dids << ds['id'] + ds['extended_metadata'] = get_extended_metadata(child) + ds + end + @dataset_ids[analysis['id']] = dids + analysis['preview_img'] = dids + analysis + end + + def update_analysis(analysis, attachments, code_logs) + analysis['dataset_doi'] = analysis.full_doi if analysis.respond_to? :full_doi + analysis['pub_id'] = analysis.publication&.id if analysis.respond_to? :publication + analysis['preview_img'] = preview_img(@dataset_ids[analysis['id']], attachments) + analysis['code_log'] = code_logs.find { |cl| cl.source_id == analysis['id'] }.attributes + analysis['children'].each do |ds_entity| + atts = attachments.select { |a| a.attachable_id == ds_entity['id'] } + ds_entity['attachments'] = Entities::AttachmentEntity.represent(atts) + gds = Dataset.find_by(element_type: 'Container', element_id: ds_entity['id']) + ds_entity['dataset'] = Entities::DatasetEntity.represent(gds) if gds.present? + end + analysis + end + def preview_img(container_ids, attachments) attachments = attachments.select do |a| a.thumb == true && a.attachable_type == 'Container' && container_ids.include?(a.attachable_id) end + image_atts = attachments.select do |a_img| a_img&.content_type&.match(Regexp.union(%w[jpg jpeg png tiff])) end + image_atts = image_atts.sort_by{ |a_img| a_img[:id] }.reverse + attachment = image_atts[0] || attachments[0] preview = attachment.read_thumbnail if attachment diff --git a/app/api/entities/doi_entity.rb b/app/api/entities/doi_entity.rb new file mode 100644 index 000000000..d636fa127 --- /dev/null +++ b/app/api/entities/doi_entity.rb @@ -0,0 +1,5 @@ +module Entities + class DoiEntity < Grape::Entity + expose :id, :inchikey, :suffix, :full_doi + end + end diff --git a/app/api/entities/generic_entity.rb b/app/api/entities/generic_entity.rb new file mode 100644 index 000000000..4d7193e20 --- /dev/null +++ b/app/api/entities/generic_entity.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# Entity module +module Entities + class GenericEntity < Grape::Entity + expose :id, :is_active, :label, :desc, :place, :released_at + + expose :element_klass_id do |obj| + obj[:element_klass_id] || 0 + end + + expose :klass_name do |obj| + obj[:name] || '' + end + + expose :ols_term_id do |obj| + obj[:ols_term_id] || '' + end + + expose :icon_name do |obj| + obj[:icon_name] || '' + end + + expose :klass_prefix do |obj| + obj[:klass_prefix] || '' + end + + expose :is_generic do |obj| + obj[:is_generic] || true + end + + expose :uuid do |obj| + obj[:uuid] || '' + end + + expose :properties_release do |obj| + obj[:properties_release] || {} + end + + expose :element_klass do |obj| + if obj[:element_klass_id] + Entities::GenericEntity.represent(obj.element_klass) + else + {} + end + end + + expose :identifier do |obj| + obj[:identifier] || '' + end + end +end diff --git a/app/api/entities/generic_public_entity.rb b/app/api/entities/generic_public_entity.rb new file mode 100644 index 000000000..d2bde0e2c --- /dev/null +++ b/app/api/entities/generic_public_entity.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Entity module +module Entities + class GenericPublicEntity < GenericEntity + unexpose :id + unexpose :is_active + unexpose :place + unexpose :element_klass_id + unexpose :ols_term_id + unexpose :is_generic + unexpose :uuid + unexpose :properties_release + unexpose :element_klass + end +end diff --git a/app/api/entities/molecule_publication_list_entity.rb b/app/api/entities/molecule_publication_list_entity.rb new file mode 100644 index 000000000..a4d6f6af0 --- /dev/null +++ b/app/api/entities/molecule_publication_list_entity.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Entity module +module Entities + class MoleculePublicationListEntity < Grape::Entity + expose :id, :tag, :iupac_name, :sum_formular, :cano_smiles, :inchikey, :inchistring , :molecule_svg_file, :sample_svg_file, :sid + # expose :segments, using: Entities::SegmentEntity + + expose :embargo do |obj| + obj[:embargo] || '' + end + + # expose :tag do |obj| + # obj[:tag] || {} + # end + + expose :author_name do |obj| + obj[:author_name] || '' + end + + expose :pub_id do |obj| + obj[:pub_id] || '' + end + + expose :published_at do |obj| + obj[:published_at] || '' + end + + expose :ana_cnt do |obj| + obj[:ana_cnt] || '' + end + + end +end diff --git a/app/api/entities/publication_entity.rb b/app/api/entities/publication_entity.rb new file mode 100644 index 000000000..b6a049aeb --- /dev/null +++ b/app/api/entities/publication_entity.rb @@ -0,0 +1,24 @@ +module Entities + class PublicationEntity < Grape::Entity + expose :element_id, :element_type, :taggable_data + expose :svg + expose :analysis_type + expose :children, as: 'children', using: Entities::PublicationEntity + expose :dois + + def dois + # od = object.descendants&.sort { |x, y| y.id <=> x.id } + # dois = od&.map{ |o| o.taggable_data['doi'] } + + ([object] + object.descendants)&.map{ |o| o.doi.full_doi } + end + def svg + s = object.element&.reaction_svg_file if object.element_type == 'Reaction' + s = object.element&.sample_svg_file if object.element_type == 'Sample' + s + end + def analysis_type + analysis_type = object.element&.extended_metadata['kind'] if object.element_type == 'Container' + end + end +end diff --git a/app/api/entities/reaction_entity.rb b/app/api/entities/reaction_entity.rb new file mode 100644 index 000000000..67904825f --- /dev/null +++ b/app/api/entities/reaction_entity.rb @@ -0,0 +1,22 @@ +module Entities + class ReactionEntity < Grape::Entity + expose :id, documentation: { type: "Integer", desc: "Reaction's unique id"} + expose :name, :short_label, :description, :publication, :reaction_svg_file, + :rinchi_long_key, :rinchi_short_key, :rinchi_string + expose :rinchi_web_key, if: -> (obj, opts) { obj.respond_to? :rinchi_web_key} + # expose :analysis_samples_reactions, if: -> (obj, opts) { obj.respond_to? :analysis_samples_reactions} + expose :container, using: Entities::ContainerEntity + expose :products, using: Entities::SampleEntity + expose :doi, using: Entities::DoiEntity + expose :status, if: -> (obj, opts) { obj.respond_to? :status} + expose :tlc_description, if: -> (obj, opts) { obj.respond_to? :tlc_description} + expose :tlc_solvents, if: -> (obj, opts) { obj.respond_to? :tlc_solvents} + expose :temperature, if: -> (obj, opts) { obj.respond_to? :temperature} + expose :timestamp_start, if: -> (obj, opts) { obj.respond_to? :timestamp_start} + expose :timestamp_stop, if: -> (obj, opts) { obj.respond_to? :timestamp_stop} + expose :observation, if: -> (obj, opts) { obj.respond_to? :observation} + expose :rf_value, if: -> (obj, opts) { obj.respond_to? :rf_value} + expose :embargo, if: -> (obj, opts) { obj.respond_to? :embargo} + expose :duration + end +end diff --git a/app/api/entities/reaction_publication_list_entity.rb b/app/api/entities/reaction_publication_list_entity.rb new file mode 100644 index 000000000..439ec9131 --- /dev/null +++ b/app/api/entities/reaction_publication_list_entity.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Entity module +module Entities + class ReactionPublicationListEntity < Grape::Entity + expose :id, :reaction_svg_file, :name, :new_version + + expose :embargo do |obj| + obj[:embargo] || '' + end + + expose :taggable_data do |obj| + obj[:taggable_data] || {} + end + + expose :pub_id do |obj| + obj[:pub_id] || '' + end + + expose :published_at do |obj| + obj[:published_at] || '' + end + + expose :ana_cnt do |obj| + obj[:ana_cnt] || '' + end + end +end diff --git a/app/api/entities/sample_attr_entity.rb b/app/api/entities/sample_attr_entity.rb index 5f291bcb8..abea827d9 100644 --- a/app/api/entities/sample_attr_entity.rb +++ b/app/api/entities/sample_attr_entity.rb @@ -5,14 +5,16 @@ class SampleAttrEntity < Grape::Entity expose :type, :name, :short_label, :description, :created_at, :updated_at, :target_amount_value, :target_amount_unit, :real_amount_value, :location, :real_amount_unit, :molfile, :solvent, :molarity_value, :molarity_unit, - :is_top_secret, :is_restricted, :external_label, :analyses, :purity, + :is_top_secret, :is_restricted, :external_label, :analyses, :links, :purity, :children_count, :parent_id, :imported_readout, :_contains_residues, :sample_svg_file, :density, :boiling_point, :melting_point, :stereo, :reaction_description, :container, :metrics, :pubchem_tag, :xref, :code_log, + :can_update, :can_copy, :can_publish, :molecule_name_hash, #:molecule_computed_props, :showed_name, :user_labels, :decoupled, - :molecular_mass, :sum_formula + :molecular_mass, :sum_formula, + :created_by def created_at object.created_at.strftime("%d.%m.%Y, %H:%M") diff --git a/app/api/entities/sample_entity.rb b/app/api/entities/sample_entity.rb index cf07a9d1d..47d7289fd 100644 --- a/app/api/entities/sample_entity.rb +++ b/app/api/entities/sample_entity.rb @@ -4,10 +4,20 @@ class SampleEntity < Entities::SampleAttrEntity expose :container, using: Entities::ContainerEntity expose :tag expose :segments, using: Entities::SegmentEntity + expose :publication + expose :doi, using: Entities::DoiEntity expose :residues expose :elemental_compositions, using: Entities::ElementalCompositionEntity expose :code_log, using: Entities::CodeLogEntity + expose :is_repo_public + + def is_repo_public + cols = object.tag&.taggable_data['collection_labels']&.select do |c| + c['id'] == ENV['PUBLIC_COLL_ID']&.to_i || c['id'] == ENV['SCHEME_ONLY_REACTIONS_COLL_ID']&.to_i + end + (cols && cols.length > 0) || false + end class Level0 < SampleEntity include SamplePolicySerializable diff --git a/app/api/entities/segment_entity.rb b/app/api/entities/segment_entity.rb index f7a0f1f42..952a22ce5 100644 --- a/app/api/entities/segment_entity.rb +++ b/app/api/entities/segment_entity.rb @@ -4,7 +4,11 @@ module Entities # Segment entity class SegmentEntity < Grape::Entity - expose :id, :segment_klass_id, :element_type, :element_id, :properties, :uuid, :klass_uuid + expose :id, :segment_klass_id, :element_type, :element_id, :properties, :uuid, :klass_uuid, :klass_label + + def klass_label + object.segment_klass.label + end def properties return unless object.respond_to? :properties diff --git a/app/api/entities/user_entity.rb b/app/api/entities/user_entity.rb index af3072a6a..d702e3ce1 100644 --- a/app/api/entities/user_entity.rb +++ b/app/api/entities/user_entity.rb @@ -17,13 +17,40 @@ class UserEntity < Grape::Entity expose :locked_at, if: -> (obj, opts) { obj.respond_to? :locked_at} expose :is_templates_moderator, documentation: { type: "Boolean", desc: "ketcherails template administrator" } expose :molecule_editor, documentation: { type: 'Boolean', desc: 'molecule administrator' } + expose :converter_admin, documentation: { type: 'Boolean', desc: 'converter administrator' } expose :account_active, documentation: { type: 'Boolean', desc: 'User Account Active or Inactive' } expose :matrix, documentation: { type: 'Integer', desc: "User's matrix" } expose :counters + expose :affiliations, :current_affiliations + expose :is_article_editor, :is_howto_editor, :is_reviewer + expose :orcid + + def affiliations + a = {} + object.affiliations.select( + 'id', + 'affiliations.department || chr(44)|| chr(32) || affiliations.organization || chr(44)|| chr(32) || affiliations.country as aff' + ).reduce(a){|acc, affiliation| a[affiliation.id] = affiliation.aff} + a + end + + def orcid + object.orcid + end + + def current_affiliations + a = {} + object.current_affiliations.select( + 'id', + 'affiliations.department || chr(44)|| chr(32) || affiliations.organization || chr(44)|| chr(32) || affiliations.country as aff' + ).reduce(a){|acc, affiliation| a[affiliation.id] = affiliation.aff} + a + end def samples_count object.counters['samples'].to_i end + def reactions_count object.counters['reactions'].to_i end diff --git a/app/api/helpers/chem_scanner_helpers.rb b/app/api/helpers/chem_scanner_helpers.rb deleted file mode 100644 index 2c3b70f9d..000000000 --- a/app/api/helpers/chem_scanner_helpers.rb +++ /dev/null @@ -1,217 +0,0 @@ -# frozen_string_literal: true - -# Helpers function for CDX parser -module ChemScannerHelpers - require 'base64' - require 'open3' - - extend Grape::API::Helpers - - def read_docx(path, get_mol) - docx = ChemScanner::Docx.new - docx.read(path) - - info_arr = [] - docx.cdx_map.each_value do |info| - cdx = info[:cdx] - cdx_info = info_from_parser(cdx, get_mol) - img_b64 = info[:img_b64] - img_ext = info[:img_ext] - - info = { info: cdx_info, cdUid: SecureRandom.hex(10) } - if img_ext == '.png' - info[:svg] = "data:image/png;base64,#{img_b64}" - elsif %w[.emf .wmf].include?(img_ext) - info[:svg] = b64metafile_to_svg(img_b64, img_ext) - end - - info_arr.push(info) - end - - { cds: info_arr } - end - - def read_doc(path, get_mol) - doc = ChemScanner::Doc.new - doc.read(path) - - info_arr = [] - doc.cdx_map.each_value do |cdx| - cdx_info = info_from_parser(cdx, get_mol) - info_arr.push( - b64cdx: Base64.encode64(cdx.raw_data), - info: cdx_info, - cdUid: SecureRandom.hex(10) - ) - end - - { cds: info_arr } - end - - def read_cdx(path, get_mol) - cdx = ChemScanner::Cdx.new - cdx.read(path) - - { - cds: [ - { - b64cdx: Base64.encode64(cdx.raw_data), - info: info_from_parser(cdx, get_mol), - cdUid: SecureRandom.hex(10) - } - ] - } - end - - def read_cdxml(path, get_mol) - cdxml = ChemScanner::Cdxml.new - cdxml.read(path) - - { - cds: [ - { - cdxml: cdxml.raw_data, - info: info_from_parser(cdxml, get_mol), - cdUid: SecureRandom.hex(10) - } - ] - } - end - - def read_xml(path, get_mol) - eln = ChemScanner::PerkinEln.new - eln.read(path) - - info_arr = [] - eln.scheme_list.each do |scheme| - info = info_from_parser(scheme, get_mol) - info_arr.push( - cdxml: scheme.cdxml, - info: info, - cdUid: SecureRandom.hex(10) - ) - end - - { cds: info_arr } - end - - def info_from_parser(parser, get_mol) - objs = get_mol ? parser.molecules : parser.reactions - return [] if objs.empty? - - infos = [] - objs.each do |obj| - info = extract_info(obj, get_mol) - infos.push(info) unless info.empty? - end - - infos - end - - def read_uploaded_file(file, get_mol) - filepath = file.to_path - extn = File.extname(filepath).downcase - func_name = "read_#{extn[1..-1]}".to_sym - return {} unless respond_to?(func_name) - - begin - return send(func_name, filepath, get_mol) - rescue StandardError => e - Rails.logger.error("Error while parsing: #{e}") - return {} - end - end - - def extract_info(obj, get_mol) - get_mol ? mol_info(obj, true) : reaction_info(obj) - end - - def reaction_info(reaction) - return {} if reaction.reactants.count.zero? || reaction.products.count.zero? - - list_mol_info = ->(arr) { arr.map { |m| mol_info(m) } } - - { - id: reaction.arrow_id, - svg: build_reaction_svg(reaction), - smi: reaction.reaction_smiles, - description: reaction.description, - details: reaction.details.to_h || {}, - status: reaction.status, - temperature: reaction.temperature, - time: reaction.time, - yield: reaction.yield, - steps: reaction.steps, - abbreviations: abbreviation_mdl(reaction.reagent_smiles), - reactants: list_mol_info.call(reaction.reactants), - reagents: list_mol_info.call(reaction.reagents), - products: list_mol_info.call(reaction.products) - } - end - - def abbreviation_mdl(abbs) - abbs.map { |s| - { - smi: s, - mdl: Chemotion::OpenBabelService.molfile_from_cano_smiles(s) - } - } - end - - def build_reaction_svg(reaction) - mdl_info = { - reactants_mdl: reaction.reactants.map(&:mdl), - reagents_mdl: reaction.reagents.map(&:mdl), - reagents_smiles: reaction.reagent_smiles, - products_mdl: reaction.products.map(&:mdl) - } - - SVG::ReactionComposer.cs_reaction_svg_from_mdl( - mdl_info, - ChemScanner.solvents.values - ) - end - - def mol_info(mol, getSvg = false) - return {} if mol.nil? - - alias_info = (mol.atom_map || {}).each_with_object([]) do |(key, atom), arr| - next unless atom.is_alias || atom.alias_text.empty? - - arr.push(id: key, text: atom.alias_text) - end - - res = { - id: mol.id, - description: mol.text || '', - details: mol.details.to_h || {}, - smi: mol.cano_smiles || '', - mdl: mol.mdl, - label: mol.label, - alias: alias_info || [] - } - - res[:svg] = Chemotion::OpenBabelService.mdl_to_trans_svg(mol.mdl) if getSvg - - res - end - - def b64metafile_to_svg(b64emf, extn) - emf_file = Tempfile.new(['chemscanner', extn]) - svg_file = Tempfile.new(['chemscanner', '.svg']) - IO.binwrite(emf_file.path, Base64.decode64(b64emf)) - - emf_file.close - svg_file.close - - cmd = "inkscape -l #{svg_file.path} #{emf_file.path}" - Open3.popen3(cmd) do |_, _, _, wait_thr| wait_thr.value end - - svg = File.read(svg_file.path) - - emf_file.unlink - svg_file.unlink - - svg - end -end diff --git a/app/api/helpers/chemscanner_helpers.rb b/app/api/helpers/chemscanner_helpers.rb new file mode 100644 index 000000000..ee5ae79de --- /dev/null +++ b/app/api/helpers/chemscanner_helpers.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# ChemScanner gem helper +module ChemscannerHelpers + require 'base64' + require 'open3' + + extend Grape::API::Helpers + + def build_reaction_svg(reaction) + reaction_mdl_info = { + starting_materials: reaction.reactants.map(&:mdl), + reactants: reaction.reagents.map(&:mdl), + solvents: reaction.solvents.map(&:mdl), + products: reaction.products.map(&:mdl) + } + + SVG::ChemscannerComposer.reaction_svg_from_mdl(reaction_mdl_info) + end + + def serialize_outputs(files, schemes, reactions, molecules, get_mol) + display = get_mol ? 'molecules' : 'reactions' + + serialized_files = files.map { |f| + Chemscanner::SourceSerializer.new(f).serializable_hash + } + + serialized_schemes = schemes.map { |s| + ss = Chemscanner::SchemeSerializer.new(s).serializable_hash + ss.merge(display: display) + } + + serialized_reactions = reactions.map { |r| + sr = Chemscanner::ReactionSerializer.new(r).serializable_hash + next sr if get_mol + + sr.merge(svg: build_reaction_svg(r)) + } + + serialized_molecules = molecules.map { |m| + sm = Chemscanner::MoleculeSerializer.new(m).serializable_hash + next sm unless get_mol + + sm.merge(svg: Chemotion::RdkitService.svg_from_molfile(m.mdl)) + } + + { + files: serialized_files, + schemes: serialized_schemes, + reactions: serialized_reactions, + molecules: serialized_molecules + } + end + + def serialize_storage_outputs(files, schemes, reactions, molecules) + serialized_files = files.map { |f| + Chemscanner::SourceSerializer.new(f).serializable_hash + } + + serialized_schemes = schemes.map { |s| + Chemscanner::StorageSchemeSerializer.new(s).serializable_hash + } + + serialize_items = lambda do |items| + items.map { |item| + { + id: item.id, + extended_metadata: item.extended_metadata, + imported_id: item.imported_id, + is_approved: item.is_approved, + scheme_id: item.scheme.id + } + } + end + + { + files: serialized_files, + schemes: serialized_schemes, + reactions: serialize_items.call(reactions), + molecules: serialize_items.call(molecules) + } + end +end diff --git a/app/api/helpers/collection_helpers.rb b/app/api/helpers/collection_helpers.rb index d392eac43..c624788b0 100644 --- a/app/api/helpers/collection_helpers.rb +++ b/app/api/helpers/collection_helpers.rb @@ -103,12 +103,17 @@ def fetch_source_collection_for_assign end def set_var(c_id = params[:collection_id], is_sync = params[:is_sync]) + public_col = !is_sync && [Collection.public_collection_id, Collection.scheme_only_reactions_collection_id].include?(params[:collection_id]) + if public_col + @c_id = params[:collection_id] if public_col + else @c_id = fetch_collection_id_w_current_user(c_id, is_sync) + end @c = Collection.find_by(id: @c_id) cu_id = current_user&.id - @is_owned = cu_id && ((@c.user_id == cu_id && !@c.is_shared) || @c.shared_by_id == cu_id) + @is_owned = cu_id && ((@c&.user_id == cu_id && !@c&.is_shared) || @c&.shared_by_id == cu_id) - @dl = { + @dl ||= { permission_level: 10, sample_detail_level: 10, reaction_detail_level: 10, @@ -118,7 +123,7 @@ def set_var(c_id = params[:collection_id], is_sync = params[:is_sync]) element_detail_level: 10, } - @dl = detail_level_for_collection(c_id, is_sync) unless @is_owned + @dl = detail_level_for_collection(c_id, is_sync) unless @is_owned || [Collection.public_collection_id, Collection.scheme_only_reactions_collection_id].include?(@c_id) @pl = @dl[:permission_level] @dl_s = @dl[:sample_detail_level] @dl_r = @dl[:reaction_detail_level] @@ -127,4 +132,27 @@ def set_var(c_id = params[:collection_id], is_sync = params[:is_sync]) @dl_rp = @dl[:researchplan_detail_level] @dl_e = @dl[:element_detail_level] end + + def check_params_collection_id + params[:collection_id] = case params[:collection_id] + when 'public' + Collection.public_collection_id + when 'schemeOnly' + Collection.scheme_only_reactions_collection_id + else + params[:collection_id] + end + end + + def set_var_for_unsigned_user + params[:is_sync] = false + @dl = { + permission_level: 0, + sample_detail_level: 10, + reaction_detail_level: 10, + wellplate_detail_level: 0, + screen_detail_level: 0, + researchplan_detail_level: 0 + } + end end diff --git a/app/api/helpers/compound_helpers.rb b/app/api/helpers/compound_helpers.rb new file mode 100644 index 000000000..2805ee65c --- /dev/null +++ b/app/api/helpers/compound_helpers.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Compound data helper +module CompoundHelpers + extend Grape::API::Helpers + + def build_xvial_com(inchikey, uid) + xvial_com = { allowed: false, hasData: 0, data: [], hasSample: false } + com_config = Rails.configuration.compound_opendata + return xvial_com unless com_config.present? && inchikey.present? && uid.present? + + xvial_com = CompoundOpenData.where('x_inchikey = ?', inchikey).order(x_created_at: :desc) + { + allowed: com_config.allowed_uids.include?(uid), + hasData: xvial_com.length.positive?, + data: com_config.allowed_uids.include?(uid) ? xvial_com : [], + hasSample: false + } + end + + def join_xvial_sql(req_xvial = false, link = 'samples') + xvial_count_sql = <<~SQL + inner join element_tags e on e.taggable_type = 'Sample' and e.taggable_id = #{link}.id and (e.taggable_data -> 'xvial' is not null and e.taggable_data -> 'xvial' ->> 'num' != '') + SQL + xvial_count_sql = '' unless req_xvial + xvial_count_sql + end + + def get_xvial_sql(req_xvial = false, link = 'samples') + xvial_com_sql = <<~SQL + inner join molecules m on m.id = samples.molecule_id + inner join com_xvial(true) a on a.x_inchikey = m.inchikey + SQL + if req_xvial + xvial_com_sql = <<~SQL + inner join element_tags e on e.taggable_type = 'Sample' and e.taggable_id = #{link}.id + inner join com_xvial(true) a on a.x_data ->> 'xid' = e.taggable_data -> 'xvial' ->> 'num' + SQL + end + xvial_com_sql + end + + def get_xdata(inchikey, sid, req_xvial = false) + return [] unless req_xvial + + com_config = Rails.configuration.compound_opendata + return [] unless com_config.present? && inchikey.present? + + xvial_com_sql = <<~SQL + inner join element_tags e on e.taggable_type = 'Sample' and e.taggable_id = #{sid} + SQL + + data = CompoundOpenData.where("compound_open_data.x_data ->> 'xid' = e.taggable_data -> 'xvial' ->> 'num'").joins(xvial_com_sql).order(x_created_at: :desc).pluck :x_data + + x_data = data.map do |obj| + { affiliation: obj['affiliation'], provided_by: obj['provided_by'], group: obj['group'] } + end + x_data + end +end diff --git a/app/api/helpers/container_helpers.rb b/app/api/helpers/container_helpers.rb index 25d395e95..c519cdbe2 100644 --- a/app/api/helpers/container_helpers.rb +++ b/app/api/helpers/container_helpers.rb @@ -103,13 +103,16 @@ def create_or_update_attachments(container, attachments) end def delete_containers_and_attachments(container) - Attachment.where_container(container[:id]).destroy_all + # delete the link, not the container, if a link_id is present + container_id = container[:link_id].present? ? container[:link_id]: container[:id] + + Attachment.where_container(container_id).destroy_all if container[:children] && container[:children].length > 0 container[:children].each do |tmp| delete_containers_and_attachments(tmp) end end - Container.where(id: container[:id]).destroy_all + Container.where(id: container_id).destroy_all end def can_update_container(container) diff --git a/app/api/helpers/embargo_helpers.rb b/app/api/helpers/embargo_helpers.rb new file mode 100644 index 000000000..8f387823d --- /dev/null +++ b/app/api/helpers/embargo_helpers.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +# A helper for submission +module EmbargoHelpers + extend Grape::API::Helpers + + def refresh_embargo_metadata(col_id) + pub = Publication.find_by(element_type: 'Collection', element_id: col_id) + col = pub&.element + if col.present? + ps = Publication.where(element_type: 'Sample', ancestry: nil, element_id: col.samples&.pluck(:id)) + pr = Publication.where(element_type: 'Reaction', ancestry: nil, element_id: col.reactions&.pluck(:id)) + + creators = [] + author_ids = [] + affiliation_ids = [] + contributors = {} + affiliations = [] + eids = [] + dois = [] + + (ps + pr)&.each do |pu| + if pu.taggable_data['scheme_only'] == true + else + eids.push(pu.id) + dois.push({ id: pu.id, element_type: pu.element_type, element_id: pu.element_id, doi: pu.doi.full_doi }) if pu.doi.present? + ctag = pu.taggable_data || {} + creators << ctag["creators"] + author_ids << ctag["author_ids"] + affiliation_ids << ctag["affiliation_ids"] + contributors = ctag["contributors"] + end + end + + et = ElementTag.find_or_create_by(taggable_id: col_id, taggable_type: 'Collection') + et_taggable_data = et.taggable_data || {} + if et_taggable_data['publication'].present? + creators << et_taggable_data['publication']["creators"] if et_taggable_data['publication']["creators"] .present? + author_ids << et_taggable_data['publication']["author_ids"] if et_taggable_data['publication']["author_ids"] .present? + affiliation_ids << et_taggable_data['publication']["affiliation_ids"] if et_taggable_data['publication']["affiliation_ids"] .present? + end + + affiliations = Affiliation.where(id: affiliation_ids.flatten) + affiliations_output = {} + affiliations.each do |aff| + affiliations_output[aff.id] = aff.output_full + end + + tag = pub.taggable_data || {} + tag["creators"] = creators.flatten.uniq + tag["author_ids"] = author_ids.flatten.uniq + tag["affiliation_ids"] = affiliation_ids.flatten.uniq + tag["contributors"] = contributors + tag["affiliations"] = affiliations_output + tag["eids"] = eids + tag["element_dois"] = dois + pub.update!(taggable_data: tag) + pub.persit_datacite_metadata_xml! + pub + end + end + + def fetch_embargo_collection(cid, current_user) + if (cid == 0) + chemotion_user = User.chemotion_user + new_col_label = current_user.initials + '_' + Time.now.strftime('%Y-%m-%d') + col_check = Collection.where([' label like ? ', new_col_label + '%']) + new_col_label = new_col_label << '_' << (col_check&.length + 1)&.to_s if col_check&.length.positive? + new_embargo_col = Collection.create!(user: chemotion_user, label: new_col_label, ancestry: current_user.publication_embargo_collection.id) + SyncCollectionsUser.find_or_create_by(user: current_user, shared_by_id: chemotion_user.id, collection_id: new_embargo_col.id, + permission_level: 0, sample_detail_level: 10, reaction_detail_level: 10, + fake_ancestry: current_user.publication_embargo_collection.sync_collections_users.first.id.to_s) + #embargo = Embargo.create!(name: new_embargo_col.label, collection_id: new_embargo_col.id, created_by: current_user.id) + d = Doi.create_for_element!(new_embargo_col) + Publication.create!( + state: Publication::STATE_PENDING, + element: new_embargo_col, + published_by: current_user.id, + doi: d, + taggable_data: { label: new_embargo_col.label, col_doi: d.full_doi } + ) + new_embargo_col + else + Collection.find(cid) + end + end + + + + def find_embargo_collection(root_publication) + has_embargo_col = root_publication.element&.collections&.select { |c| c['ancestry'].to_i == User.find(root_publication.published_by).publication_embargo_collection.id } + has_embargo_col && has_embargo_col.length > 0 ? has_embargo_col.first : OpenStruct.new(label: '') + end + + def create_embargo() + end + +end diff --git a/app/api/helpers/public_helpers.rb b/app/api/helpers/public_helpers.rb new file mode 100644 index 000000000..4f2602824 --- /dev/null +++ b/app/api/helpers/public_helpers.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Helper for public API +module PublicHelpers + extend Grape::API::Helpers + include ApplicationHelper + + def send_notification(attachment, user, status, has_error = false) + data_args = { 'filename': attachment.filename, 'comment': 'the file has been updated' } + level = 'success' + if has_error + data_args['comment'] = ' an error has occurred, the file is not changed.' + level = 'error' + elsif status == 4 + data_args['comment'] = ' file has not changed.' + level = 'info' + elsif @status == 7 + data_args['comment'] = ' an error has occurred while force saving the document, please review your changes.' + level = 'error' + end + Message.create_msg_notification( + channel_subject: Channel::EDITOR_CALLBACK, message_from: user.id, + data_args: data_args, attach_id: attachment.id, research_plan_id: attachment.attachable_id, level: level + ) + end + + def de_encode_json(json, key = '', viv = '', encode = true) + if encode + encode_json(json) + else + decode_json(json, key, viv) + end + end +end diff --git a/app/api/helpers/report_helpers.rb b/app/api/helpers/report_helpers.rb index 63ee5f7e5..97563a292 100644 --- a/app/api/helpers/report_helpers.rb +++ b/app/api/helpers/report_helpers.rb @@ -209,6 +209,8 @@ def build_sql_sample_sample(columns, c_id, ids, checkedAll = false) , res.residue_type, s.molfile_version, s.decoupled, s.molecular_mass as "molecular mass (decoupled)", s.sum_formula as "sum formula (decoupled)" , s.stereo->>'abs' as "stereo_abs", s.stereo->>'rel' as "stereo_rel" , #{columns} + , ets.taggable_data#>>'{publication,doi}' as "doi" + , 'https://dx.doi.org/' || (ets.taggable_data#>>'{publication,doi}') as "url" from ( select s.id as s_id @@ -231,6 +233,7 @@ def build_sql_sample_sample(columns, c_id, ids, checkedAll = false) left join molecules m on s.molecule_id = m.id left join molecule_names mn on s.molecule_name_id = mn.id left join residues res on res.sample_id = s.id + left join element_tags ets on ets.taggable_type = 'Sample' and ets.taggable_id = s.id order by #{order}; SQL end @@ -263,6 +266,7 @@ def build_sql_sample_analyses(columns, c_id, ids, checkedAll = false) , anac.extended_metadata->'content' as "content" , anac.extended_metadata->'status' as "status" , clg.id as uuid + , ets.taggable_data#>>'{publication,analysis_doi}' as "doi" , (select array_to_json(array_agg(row_to_json(dataset))) from ( select datc."name" as "dataset name" @@ -284,6 +288,7 @@ def build_sql_sample_analyses(columns, c_id, ids, checkedAll = false) inner join container_hierarchies ch on cont.id = ch.ancestor_id and ch.generations = 2 inner join containers anac on anac.id = ch.descendant_id left join code_logs clg on clg."source" = 'container' and clg.source_id = anac.id + left join element_tags ets on ets.taggable_type = 'Container' and ets.taggable_id = anac.id where cont.containable_type = '#{cont_type}' and cont.containable_id = #{t}.id ) analysis ) as analyses @@ -413,6 +418,10 @@ def build_sql_reaction_sample(columns, c_id, ids, checkedAll = false) when r_s.type = 'ReactionsReactantSample' then '2 reactant' when r_s.type = 'ReactionsSolventSample' then '3 solvent' when r_s.type = 'ReactionsProductSample' then '4 product' end as "type" + , ets.taggable_data#>>'{publication,doi}' as "doi" + , 'https://dx.doi.org/' || (ets.taggable_data#>>'{publication,doi}') as "url" + , etr.taggable_data#>>'{publication,doi}' as "r doi" + , 'https://dx.doi.org/' || (etr.taggable_data#>>'{publication,doi}') as "r url" from ( select s.id as s_id @@ -440,6 +449,8 @@ def build_sql_reaction_sample(columns, c_id, ids, checkedAll = false) left join molecules m on s.molecule_id = m.id left join molecule_names mn on s.molecule_name_id = mn.id left join residues res on res.sample_id = s.id + left join element_tags ets on ets.taggable_type = 'Sample' and ets.taggable_id = s.id + left join element_tags etr on etr.taggable_type = 'Reaction' and etr.taggable_id = r.id order by #{order}, "type" asc, r_s.position asc; SQL end diff --git a/app/api/helpers/repository_helpers.rb b/app/api/helpers/repository_helpers.rb new file mode 100644 index 000000000..ba22cf5a7 --- /dev/null +++ b/app/api/helpers/repository_helpers.rb @@ -0,0 +1,310 @@ +module RepositoryHelpers + extend Grape::API::Helpers + + def get_pub_reaction(id) + reaction = Reaction.where('id = ?', id) + .select( + <<~SQL + reactions.id, reactions.name, reactions.description, reactions.reaction_svg_file, reactions.short_label, + reactions.status, reactions.tlc_description, reactions.tlc_solvents, reactions.rf_value, + reactions.temperature, reactions.timestamp_start,reactions.timestamp_stop,reactions.observation, + reactions.rinchi_string, reactions.rinchi_long_key, reactions.rinchi_short_key,reactions.rinchi_web_key, + (select label from publication_collections where (elobj ->> 'element_type')::text = 'Reaction' and (elobj ->> 'element_id')::integer = reactions.id) as embargo, + (select json_extract_path(taggable_data::json, 'publication') from publications where element_type = 'Reaction' and element_id = reactions.id) as publication, + (select taggable_data -> 'new_version' -> 'id' from element_tags where taggable_type = 'Reaction' and taggable_id = reactions.id) as new_version, + (select taggable_data -> 'versions' from element_tags where taggable_type = 'Reaction' and taggable_id = reactions.id) as versions, + reactions.duration + SQL + ) + .includes( + container: :attachments + ).last + literatures = get_literature(reaction.id,'Reaction') || [] + reaction.products.each do |p| + literatures += get_literature(p.id,'Sample') + end + + pub = Publication.find_by(element_type: 'Reaction', element_id: reaction.id) + pub_info = (pub.review.present? && pub.review['info'].present? && pub.review['info']['comment']) || '' + infos = {} + ana_infos = {} + pd_infos = {} + pub.descendants.each do |pp| + review = pp.review || {} + info = review['info'] || {} + next if info.empty? + if pp.element_type == 'Sample' + pd_infos[pp.element_id] = info['comment'] + else + ana_infos[pp.element_id] = info['comment'] + end + end + + schemeList = get_reaction_table(reaction.id) + entities = Entities::ReactionEntity.represent(reaction, serializable: true) + entities[:products].each do |p| + pub_product = p + p[:xvialCom] = build_xvial_com(p[:molecule][:inchikey], current_user&.id) + pub_product_tag = pub_product[:tag]['taggable_data'] + next if pub_product_tag.nil? + + unless pub_product_tag['versions'].nil? + p[:versions] = pub_product_tag['versions'].reduce([]) do |versions, version_id| + sample = Sample.find(version_id) + if sample.publication.state == 'completed' + versions.append Entities::SampleEntity.represent(sample, serializable: true) + end + versions + end + end + + xvial = pub_product_tag['xvial'] && pub_product_tag['xvial']['num'] + next unless xvial.present? + + unless current_user.present? && User.reviewer_ids.include?(current_user.id) + pub_product_tag['xvial']['num'] = 'x' + end + p[:xvialCom][:hasSample] = true + end + entities[:publication]['review']['history'] = [] + entities[:literatures] = literatures unless entities.nil? || literatures.nil? || literatures.length == 0 + entities[:schemes] = schemeList unless entities.nil? || schemeList.nil? || schemeList.length == 0 + entities[:isLogin] = current_user.present? + entities[:embargo] = reaction.embargo + entities[:infos] = { pub_info: pub_info, pd_infos: pd_infos, ana_infos: ana_infos } + entities[:isReviewer] = current_user.present? && User.reviewer_ids.include?(current_user.id) ? true : false + entities[:isPublisher] = (current_user.present? && current_user.id == pub.published_by) + entities[:new_version] = reaction.new_version + entities[:versions] = reaction.versions + entities[:elementType] = 'reaction' + entities[:segments] = Entities::SegmentEntity.represent(reaction.segments) + entities + end + + def get_pub_molecule(id, adv_flag=nil, adv_type=nil, adv_val=nil) + molecule = Molecule.find(id) + xvial_com = build_xvial_com(molecule.inchikey, current_user&.id) + pub_id = Collection.public_collection_id + if adv_flag.present? && adv_flag == true && adv_type.present? && adv_type == 'Authors' && adv_val.present? + adv = <<~SQL + INNER JOIN publication_authors rs on rs.element_id = samples.id and rs.element_type = 'Sample' and rs.state = 'completed' + and rs.author_id in ('#{adv_val.join("','")}') + SQL + else + adv = '' + end + + pub_samples = Collection.public_collection.samples + .includes(:molecule,:tag).where("samples.molecule_id = ?", molecule.id) + .where( + <<~SQL + samples.id in ( + SELECT samples.id FROM samples + INNER JOIN collections_samples cs on cs.collection_id = #{pub_id} and cs.sample_id = samples.id and cs.deleted_at ISNULL + INNER JOIN publications pub on pub.element_type='Sample' and pub.element_id=samples.id and pub.deleted_at ISNULL + #{adv} + ) + SQL + ) + .select( + <<~SQL + samples.*, + (select published_at from publications where element_type='Sample' and element_id=samples.id and deleted_at is null) as published_at, + (select taggable_data -> 'new_version' -> 'id' from element_tags where taggable_type = 'Sample' and taggable_id = samples.id) as new_version, + (select taggable_data -> 'versions' from element_tags where taggable_type = 'Sample' and taggable_id = samples.id) as versions, + (select taggable_data -> 'reaction_id' from element_tags where taggable_type = 'Sample' and taggable_id = samples.id) as reaction_id + SQL + ) + .order('published_at desc') + published_samples = pub_samples.map do |s| + containers = Entities::ContainerEntity.represent(s.container) + tag = s.tag.taggable_data['publication'] + #u = User.find(s.tag.taggable_data['publication']['published_by'].to_i) + #time = DateTime.parse(s.tag.taggable_data['publication']['published_at']) + #published_time = time.strftime("%A, %B #{time.day.ordinalize} %Y %H:%M") + #aff = u.affiliations.first + next unless tag + literatures = Literature.by_element_attributes_and_cat(s.id, 'Sample', 'public') + .joins("inner join users on literals.user_id = users.id") + .select( + <<~SQL + literatures.*, + json_object_agg(literals.id, literals.litype) as litype, + json_object_agg(literals.id, users.first_name || chr(32) || users.last_name) as ref_added_by + SQL + ).group('literatures.id').as_json + reaction_ids = ReactionsProductSample.where(sample_id: s.id).pluck(:reaction_id) + if reaction_ids.empty? and not s.reaction_id.nil? + reaction_ids = [s.reaction_id] + end + pub = Publication.find_by(element_type: 'Sample', element_id: s.id) + sid = pub.taggable_data["sid"] unless pub.nil? || pub.taggable_data.nil? + xvial = s.tag.taggable_data['xvial'] && s.tag.taggable_data['xvial']['num'] unless s.tag.taggable_data.nil? + if xvial.present? + unless current_user.present? && User.reviewer_ids.include?(current_user.id) + xvial = 'x' + end + end + pub_info = (pub.review.present? && pub.review['info'].present? && pub.review['info']['comment']) || '' + ana_infos = {} + pub.descendants.each do |pp| + review = pp.review || {} + info = review['info'] || {} + next if info.empty? + ana_infos[pp.element_id] = info['comment'] + end + embargo = PublicationCollections.where("(elobj ->> 'element_type')::text = 'Sample' and (elobj ->> 'element_id')::integer = #{s.id}")&.first&.label + segments = Entities::SegmentEntity.represent(s.segments) + isPublisher = (current_user.present? && current_user.id == pub.published_by) + tag.merge(analyses: containers, literatures: literatures, + sample_svg_file: s.sample_svg_file, short_label: s.short_label, + melting_point: s.melting_point, boiling_point: s.boiling_point, + sample_id: s.id, reaction_ids: reaction_ids, sid: sid, xvial: xvial, + embargo: embargo, showed_name: s.showed_name, pub_id: pub.id, ana_infos: ana_infos, + pub_info: pub_info, segments: segments, isPublisher: isPublisher, new_version: s.new_version, + versions: s.versions) + end + x = published_samples.select { |s| s[:xvial].present? } + xvial_com[:hasSample] = x.length.positive? + published_samples = published_samples.flatten.compact + { + molecule: MoleculeGuestSerializer.new(molecule).serializable_hash.deep_symbolize_keys, + published_samples: published_samples, + isLogin: current_user.nil? ? false : true, + isReviewer: (current_user.present? && User.reviewer_ids.include?(current_user.id)) ? true : false, + xvialCom: xvial_com, + elementType: 'molecule' + } + end + + def check_repo_review_permission(element) + return true if User.reviewer_ids&.include? current_user.id + pub = Publication.find_by(element_id: element.id, element_type: element.class.name) + return false if pub.nil? + return true if pub && pub.published_by == current_user.id && ( pub.state == Publication::STATE_REVIEWED || pub.state == Publication::STATE_PENDING) + return false + end + + def repo_review_info(pub, user_id, lst) + { + submitter: pub&.published_by == user_id || false, + reviewer: User.reviewer_ids&.include?(user_id) || false, + groupleader: pub&.review&.dig('reviewers')&.include?(user_id), + leaders: User.where(id: pub&.review&.dig('reviewers'))&.map{ |u| u.name }, + preapproved: pub&.review&.dig('checklist', 'glr', 'status') == true, + review_level: repo_review_level(pub&.element_id, pub&.element_type) + } + end + + def repo_review_level(id, type) + return 3 if User.reviewer_ids&.include? current_user.id + pub = Publication.find_by(element_id: id, element_type: type.classify) + return 0 if pub.nil? + return 2 if pub.published_by === current_user.id + sync_cols = pub.element.sync_collections_users.where(user_id: current_user.id) + return 1 if (sync_cols&.length > 0) + return 0 + end + + def get_literature(id, type, cat='public') + literatures = Literature.by_element_attributes_and_cat(id, type.classify, cat) + .joins("inner join users on literals.user_id = users.id") + .select( + <<~SQL + literatures.* , literals.element_type, literals.element_id, + json_object_agg(literals.id, literals.litype) as litype, + json_object_agg(literals.id, users.first_name || chr(32) || users.last_name) as ref_added_by + SQL + ).group('literatures.id, literals.element_type, literals.element_id').as_json + literatures + end + + def get_reaction_table(id) + schemeAll = ReactionsSample.where('reaction_id = ? and type != ?', id, 'ReactionsPurificationSolventSample') + .joins(:sample) + .joins("inner join molecules on samples.molecule_id = molecules.id") + .select( + <<~SQL + reactions_samples.id, + (select name from molecule_names mn where mn.id = samples.molecule_name_id) as molecule_iupac_name, + molecules.iupac_name, molecules.sum_formular, + molecules.molecular_weight, samples.name, samples.short_label, + samples.real_amount_value, samples.real_amount_unit, + samples.target_amount_value, samples.target_amount_unit, + samples.purity, samples.density, samples.external_label, + samples.molarity_value, samples.molarity_unit, + reactions_samples.equivalent,reactions_samples.scheme_yield, + reactions_samples."position" as rs_position, + case when reactions_samples."type" = 'ReactionsStartingMaterialSample' then 'starting_materials' + when reactions_samples."type" = 'ReactionsReactantSample' then 'reactants' + when reactions_samples."type" = 'ReactionsProductSample' then 'products' + when reactions_samples."type" = 'ReactionsSolventSample' then 'solvents' + when reactions_samples."type" = 'ReactionsPurificationSolventSample' then 'purification_solvents' + else reactions_samples."type" + end mat_group, + case when reactions_samples."type" = 'ReactionsStartingMaterialSample' then 1 + when reactions_samples."type" = 'ReactionsReactantSample' then 2 + when reactions_samples."type" = 'ReactionsProductSample' then 3 + when reactions_samples."type" = 'ReactionsSolventSample' then 4 + when reactions_samples."type" = 'ReactionsPurificationSolventSample' then 5 + else 6 + end type_seq + SQL + ).order('reactions_samples.position ASC').as_json + + schemeSorted = schemeAll.sort_by {|o| o['type_seq']} + solvents_sum = schemeAll.select{ |d| d['mat_group'] === 'solvents'}.sum { |r| + value = (r['real_amount_value'].nil? || r['real_amount_value'].zero?) ? r['target_amount_value'].to_f : r['real_amount_value'].to_f + unit = (r['real_amount_value'].nil? || r['real_amount_value'].zero?) ? r['target_amount_unit'] : r['real_amount_unit'] + + has_molarity = !r['molarity_value'].nil? && r['molarity_value'] > 0.0 && (r['density'] === 0.0) || false + has_density = !r['density'].nil? && r['density'] > 0.0 && (r['molarity_value'] === 0.0) || false + + molarity = r['molarity_value'] && r['molarity_value'].to_f || 1.0 + density = r['density'] && r['density'].to_f || 1.0 + purity = r['purity'] && r['purity'].to_f || 1.0 + molecular_weight = r['molecular_weight'] && r['molecular_weight'].to_f || 1.0 + + r['amount_g'] = unit === 'g'? value : unit === 'mg'? value.to_f / 1000.0 : unit === 'mol' ? (value / purity) * molecular_weight : unit === 'l' && !has_molarity && !has_density ? 0 : has_molarity ? value * molarity * molecular_weight : value * density * 1000 + r['amount_l'] = unit === 'l'? value : !has_molarity && !has_density ? 0 : has_molarity ? (r['amount_g'].to_f * purity) / (molarity * molecular_weight) : has_density ? r['amount_g'].to_f / (density * 1000) : 0 + r['amount_l'].nil? ? 0 : r['amount_l'].to_f + } + + schemeList = [] + schemeList = schemeSorted.map do |r| + scheme = {} + value = (r['real_amount_value'].nil? || r['real_amount_value'].zero?) ? r['target_amount_value'].to_f : r['real_amount_value'].to_f + unit = (r['real_amount_value'].nil? || r['real_amount_value'].zero?) ? r['target_amount_unit'] : r['real_amount_unit'] + has_molarity = !r['molarity_value'].nil? && r['molarity_value'] > 0.0 && (r['density'] === 0.0) || false + has_density = !r['density'].nil? && r['density'] > 0.0 && (r['molarity_value'] === 0.0) || false + + molarity = r['molarity_value'] && r['molarity_value'].to_f || 1.0 + density = r['density'] && r['density'].to_f || 1.0 + purity = r['purity'] && r['purity'].to_f || 1.0 + molecular_weight = r['molecular_weight'] && r['molecular_weight'].to_f || 1.0 + r['amount_g'] = unit === 'g'? value : unit === 'mg'? value.to_f / 1000.0 : unit === 'mol' ? (value / purity) * molecular_weight : unit === 'l' && !has_molarity && !has_density ? 0 : has_molarity ? value * molarity * molecular_weight : value * density * 1000 + r['amount_l'] = unit === 'l'? value : !has_molarity && !has_density ? 0 : has_molarity ? (r['amount_g'].to_f * purity) / (molarity * molecular_weight) : has_density ? r['amount_g'].to_f / (density * 1000) : 0 + + if r['mat_group'] === 'solvents' + r['equivalent'] = r['amount_l'] / solvents_sum + else + r['amount_mol'] = unit === 'mol'? value : has_molarity ? r['amount_l'] * molarity : r['amount_g'].to_f * purity / molecular_weight + r['dmv'] = !has_molarity && !has_density ? '- / -' : has_density ? + density.to_s + ' / - ' : ' - / ' + molarity.to_s + r['molarity_unit'] + end + + r.delete('real_amount_value'); + r.delete('real_amount_unit'); + r.delete('target_amount_value'); + r.delete('target_amount_unit'); + r.delete('molarity_value'); + r.delete('molarity_unit'); + r.delete('purity'); + r.delete('molecular_weight'); + r.delete('rs_position'); + r.delete('density'); + r + end + schemeList + end + +end diff --git a/app/api/helpers/submission_helpers.rb b/app/api/helpers/submission_helpers.rb new file mode 100644 index 000000000..6a4e961dc --- /dev/null +++ b/app/api/helpers/submission_helpers.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +# A helper for submission +module SubmissionHelpers + extend Grape::API::Helpers + + def ols_validation(analyses) + analyses&.each do |container| + if container.container_type == 'analysis' + error!('analyses check fail', 404) if (container.extended_metadata['kind'].match /^\w{3,4}\:\d{6,7}\s\|\s\w+/).nil? + end + end + end + + def coauthor_validation(coauthors) + coauthor_ids = [] + coauthors&.each do |coa| + val = coa.strip + usr = User.where(type: %w[Person Collaborator]).where.not(confirmed_at: nil).where('id = ? or email = ?', val.to_i, val.to_s).first + error!('invalid co-author: ' + val.to_s, 404) if usr.nil? + coauthor_ids << usr.id + end + coauthor_ids + end + + + def update_tag_doi(element) + unless element.nil? || element&.doi.nil? || element&.tag.nil? + mds = Datacite::Mds.new + et = element.tag + tag_data = (et.taggable_data && et.taggable_data['publication']) || {} + tag_data['doi'] = "#{mds.doi_prefix}/#{element&.doi.suffix}" + et.update!( + taggable_data: (et.taggable_data || {}).merge(publication: tag_data) + ) + if element&.class&.name == 'Reaction' + element&.publication.children.each do |child| + next unless child&.element&.class&.name == 'Sample' + + update_tag_doi(child.element) + end + end + end + end + + def accept_new_sample(root, sample) + pub_s = Publication.create!( + state: Publication::STATE_PENDING, + element: sample, + doi: sample&.doi, + published_by: root.published_by, + parent: root, + taggable_data: root.taggable_data + ) + sample.analyses.each do |sa| + accept_new_analysis(pub_s, sa) + end + end + + def accept_new_analysis(root, analysis, nil_analysis = true) + if nil_analysis + ap = Publication.create!( + state: Publication::STATE_PENDING, + element: analysis, + doi: analysis.doi, + published_by: root.published_by, + parent: root, + taggable_data: root.taggable_data + ) + atag = ap.taggable_data + aids = atag&.delete('analysis_ids') + aoids = atag&.delete('original_analysis_ids') + ap.save! if aids || aoids + end + analysis.children.where(container_type: 'dataset').each do |ds| + ds.attachments.each do |att| + if MimeMagic.by_path(att.filename)&.type&.start_with?('image') && att.store.file_exist? + file_path = File.join('public/images/publications/', att.id.to_s, '/', att.filename) + public_path = File.join('public/images/publications/', att.id.to_s) + FileUtils.mkdir_p(public_path) + File.write(file_path, att.store.read_file.force_encoding("utf-8")) if att.store.file_exist? + end + end + end + end + + def public_literature(root_publication) + publications = [root_publication] + root_publication.descendants + publications.each do |pub| + next unless pub.element_type == 'Reaction' || pub.element_type == 'Sample' + literals = Literal.where(element_type: pub.element_type, element_id: pub.element_id) + literals&.each { |l| l.update_columns(category: 'public') } unless literals.nil? + end + end + + def element_submit(root) + root.descendants.each { |np| np.destroy! if np.element.nil? } + root.element.reserve_suffix + root.element.reserve_suffix_analyses(root.element.analyses) if root.element.analyses&.length > 0 + root.element.analyses&.each do |a| + accept_new_analysis(root, a, Publication.find_by(element: a).nil?) + end + case root.element_type + when 'Sample' + analyses_ids = root.element.analyses.pluck(:id) + root.update!(taggable_data: root.taggable_data.merge(analysis_ids: analyses_ids)) + root.element.analyses.each do |sa| + accept_new_analysis(root, sa, Publication.find_by(element: sa).nil?) + end + + when 'Reaction' + root.element.products.each do |pd| + if (pd.analyses&.length + pd.links&.length == 0) + Publication.find_by(element_type: 'Sample', element_id: pd.id)&.destroy! + end + + next if pd.analyses&.length == 0 + pd.reserve_suffix + pd.reserve_suffix_analyses(pd.analyses) + prod_pub = Publication.find_by(element: pd) + if prod_pub.nil? + accept_new_sample(root, pd) + else + pd.analyses.each do |rpa| + accept_new_analysis(prod_pub, rpa, Publication.find_by(element: rpa).nil?) + end + end + end + end + root.reload + root.update_columns(doi_id: root.element.doi.id) unless root.doi_id == root.element.doi.id + root.descendants.each { |pub_a| + next if pub_a.element.nil? + pub_a.update_columns(doi_id: pub_a.element.doi.id) unless pub_a.doi_id == pub_a.element&.doi&.id + } + update_tag_doi(root.element) + end + +end diff --git a/app/assets/javascripts/affiliations.js b/app/assets/javascripts/affiliations.js index 96b4ea53d..6c444f01b 100644 --- a/app/assets/javascripts/affiliations.js +++ b/app/assets/javascripts/affiliations.js @@ -37,7 +37,6 @@ function attachAutoComplete(type, elementId) { attachAutoComplete("countries", "country-select") attachAutoComplete("organizations", "organization-select") attachAutoComplete("departments", "department-select") - attachAutoComplete("groups", "group-select") let userEmailInput = document.querySelector("input#user_email") if (userEmailInput) { diff --git a/app/assets/stylesheets/application.scss.erb b/app/assets/stylesheets/application.scss.erb index 7de8ce089..081ec656c 100644 --- a/app/assets/stylesheets/application.scss.erb +++ b/app/assets/stylesheets/application.scss.erb @@ -23,6 +23,63 @@ <%Bundler.load.current_dependencies.select{|dep| dep.groups.include?(:plugins)}.map(&:name).each do |plugin|%> @import "<%=plugin%>"; <%end%> +////////////////////////// + .main-image { + width: 100%; + margin-top: -10px; + position: relative; + display: inline-block; + background-color: #337ab7; +} + +.main-image p { + margin-top: 10px; + font-size: 40px; + color: white; + text-align: center; + font-weight: 600; +} + +.main-image img { + position: absolute; + overflow: visible; +} + +.main-image img#book-image { + margin-top: -100px; + width: 300px; + opacity: 0%; +} + +.main-image img#logo-image { + position: relative; +} + +.main-image img#molecule-image { + right: 10px; + margin-top: -184px; +} +.main-image::before { + content: ""; + top: 0; + left: 0; + position: absolute; + height: 100%; + width: 100%; +} + +.dfg_logo img{ + height: 25px; +} +.info-panel p { + margin: auto; +} + +.info-panel ul { + padding-left: 40%; + text-align: left; +} +////////////////////////// html, body, @@ -37,13 +94,38 @@ label.required:after { } .navbar-custom { + margin-bottom: 8px; border-radius: 0; + padding: 0px; background-color: #EFEFEF; + // : none; + // background-image: -webkit-linear-gradient(left, #EFEFEF 0%, #0d47a1 100%); + // background-image: linear-gradient(to left, #EFEFEF 0%, #0d47a1 100%); + + // background-repeat: repeat-x; + a.dropdown-toggle:hover { color: black; } } + +// .white-nav-item { +// +// a { +// color: white !important; +// +// &:hover { +// color: grey !important; +// text-decoration: none; +// } +// +// &:focus { +// color: black !important; +// text-decoration: none; +// } +// } +// } .custom-modal { width: 100%; } diff --git a/app/assets/stylesheets/chemscanner.scss b/app/assets/stylesheets/chemscanner.scss index 4302abfc2..576fdac03 100644 --- a/app/assets/stylesheets/chemscanner.scss +++ b/app/assets/stylesheets/chemscanner.scss @@ -7,11 +7,39 @@ padding-left: 0px !important; padding-right: 0px !important; + .file-filter.ag-input-wrapper { + flex-flow: row wrap; + + div:nth-child(1)::after { + content: ''; + width: 100%; + } + } + .row { margin-left: auto; margin-right: auto; } + .ag-row { + z-index: 0; + } + + .ag-row.ag-row-focus { + z-index: 1; + } + + .ag-root, + .ag-body-viewport, + .ag-body-viewport-wrapper, + .ag-center-cols-clipper, + .ag-center-cols-viewport, + .ag-header, + .ag-header-viewport, + .ag-header-row { + overflow: visible !important; + } + #chemscanner-cdjs-container { position: absolute; left:0; @@ -75,8 +103,8 @@ } } - .chemscanner-files-contents { - max-height: calc(100vh - 100px); + #chemscanner-files-list { + max-height: calc(100vh - 110px); overflow: auto; position: relative; margin-top: 20px; @@ -95,10 +123,24 @@ .scanned-reaction-desc { display: flex; + width: 100%; flex-wrap: wrap; - div { - margin: 0px 10px 10px 10px; + div:not(:first-child) { + flex-grow: 1; + margin-bottom: 10px; + } + } + + .scanned-molecule-desc { + display: flex; + width: 100%; + flex-wrap: wrap; + justify-content: center; + + div:not(:first-child) { + flex-grow: 1; + margin-bottom: 10px; } } } @@ -113,14 +155,17 @@ float: right; } + .left-btn { + float: left; + } + .selected-item { background-color: #e8f0fe; } .scanned-item { border-radius: 4px; - border: 1px solid black; - display: flow-root; + border: 2px solid black; margin-bottom: 5px; button { @@ -157,7 +202,7 @@ } > .dropdown { - float: right; + float: left; margin-left: 5px; > button { background-color: #ccc; @@ -195,6 +240,25 @@ justify-content: center; } + .chemscanner-filestorage-action-header { + display: block; + margin-top: 4px; + overflow: visible; + } + + .chemscanner-filestorage-action-row { + overflow: visible; + } + + .cs-extended-metadata-table { + line-height: 20px; + + td { + padding-right: 5px; + padding-bottom: 5px; + } + } + ::-webkit-scrollbar { background: transparent; overflow: visible; diff --git a/app/assets/stylesheets/collection_tree.css b/app/assets/stylesheets/collection_tree.css index 4af907d3c..ac930bc6d 100644 --- a/app/assets/stylesheets/collection_tree.css +++ b/app/assets/stylesheets/collection_tree.css @@ -20,6 +20,10 @@ overflow: auto; } +.title-public { + background: Lavender; +} + .tree-view li { margin-left: -13px; list-style-type: none; diff --git a/app/assets/stylesheets/common.scss b/app/assets/stylesheets/common.scss index 7066276a1..f9062a269 100644 --- a/app/assets/stylesheets/common.scss +++ b/app/assets/stylesheets/common.scss @@ -1,3 +1,11 @@ +$color-chem-blue: #337ab7; +$color-chem-logo-blue: #3a9fd3; +$color-required: #d9534f; + +.field_required { + color: $color-required; +} + // --- color --- .c-text--grey { color: grey; @@ -55,7 +63,6 @@ line-height: 1; } - .span-checkbox { -webkit-border-radius: 0; border-radius: 0; @@ -133,6 +140,11 @@ margin-left: 5px; } + .field-select-full { + margin: 5px; + flex: 100%; + } + .field-select { margin: 5px; flex: 0 0 205px; @@ -221,6 +233,79 @@ } } +.publish_tooltip { + .tooltip-inner { + text-align: left; + white-space: pre-wrap; + } +} + +.review_element_label { + color: #333; + border-style: solid; + border-width: thin; + border-radius: 4px; + padding: 4px; + font-size: 1.2em; +} + +.feature { + display: flex; + .feature-block { + display: block; + position: relative; + padding: 16px 24px 32px 24px; + text-decoration: none; + z-index: 0; + overflow: hidden; + height: 100%; + &:before { + content: ""; + position: absolute; + z-index: -1; + right: -16px; + background: unset; + height: 26px; + width: 100%; + transform: scale(1); + transform-origin: 50% 50%; + transition: transform 0.25s ease-out; + bottom: 50%; + } + &:hover:before { + transform: scale(8); + background: $color-chem-blue; + } + div { + padding: 8px; + display: inline-block; + } + h3 { + text-align: left; + } + i { + text-align: center; + display: inline-block; + color: $color-chem-blue; + position: relative; + } + } + + .feature-block:hover { + p { + transition: all 0.3s ease-out; + color: #ffffff; + } + h3 { + transition: all 0.3s ease-out; + color: #ffffff; + } + i { + color: #ffffff; + } + } +} + .research-plan-no-auth { text-align: center; border-color: #bfbfbf; @@ -283,6 +368,12 @@ .cdd .modal-dialog { left: unset !important; top: unset !important; + position: unset !important; + transform: unset !important; +} + +.cdd .fade { + opacity: unset; } .lcss-link { @@ -301,4 +392,48 @@ padding: 0px !important; background-color: #e6e6e6; } -} \ No newline at end of file +} +.home-title { + font-size: x-large; + font-weight: bolder; + text-align: center; + padding-top: 60px; + padding-bottom: 30px; + & > span { + position: relative; + padding: 0 5px; + &:before { + content: ""; + display: block; + width: 100%; + height: 1px; + position: absolute; + top: 50%; + background-color: #777; + right: 100%; + } + &:after { + content: ""; + display: block; + width: 100%; + height: 1px; + position: absolute; + top: 50%; + background-color: #777; + left: 100%; + } + } +} + +.display-false { + display: none !important; +} + +.display-true { + .initial { + display: initial; + } + .inherit { + display: inherit; + } +} diff --git a/app/assets/stylesheets/compiled-icons.css b/app/assets/stylesheets/compiled-icons.css new file mode 120000 index 000000000..78f366467 --- /dev/null +++ b/app/assets/stylesheets/compiled-icons.css @@ -0,0 +1 @@ +../../../node_modules/ag-grid-community/dist/styles/compiled-icons.css \ No newline at end of file diff --git a/app/assets/stylesheets/elements_details.css b/app/assets/stylesheets/elements_details.css index 1e3c7fca7..9e8f9cc7f 100644 --- a/app/assets/stylesheets/elements_details.css +++ b/app/assets/stylesheets/elements_details.css @@ -5,7 +5,6 @@ .button-right { float: right; right: 0px; - z-index: 1; margin-left: 2px; } @@ -128,3 +127,21 @@ width: 100%; padding: 0 !important; } + +.full-screen .element-panel-detail > .panel-body { + overflow-x: hidden; + overflow-y: hidden; + max-height: calc(100vh - 110px); +} + +.normal-screen .element-panel-detail > .panel-body { + overflow-x: hidden; + overflow-y: scroll; + max-height: calc(100vh - 240px); +} + +.element-panel-detail .full { + overflow-x: hidden; + overflow-y: scroll; + max-height: calc(100vh - 150px); +} diff --git a/app/assets/stylesheets/elements_table.scss b/app/assets/stylesheets/elements_table.scss index 0afd6c8db..07406cd83 100644 --- a/app/assets/stylesheets/elements_table.scss +++ b/app/assets/stylesheets/elements_table.scss @@ -77,6 +77,13 @@ th.drag { max-height: 22px; } +.orcid-logo { + height: 16px; + width: 16px; + margin-bottom: 2px; + margin-right: 1px; +} + .sample-list-from-date, .sample-list-to-date { float: right; margin-right: 2px; @@ -122,6 +129,11 @@ th.drag { overflow-y: scroll; } +// .list-guest-elements { +// @extend .list-elements; +// max-height: calc(100vh - 170px); +// } + .list-pagination { margin: 5px 0 0 0; } @@ -160,10 +172,56 @@ th.drag { border-top: 0 } +.review-entries > tbody > tr > td { + border-top: 1 +} + +.public-element { + height: calc(100vh - 128px); + overflow-x: hidden; + overflow-y: scroll; +} + +.public-list { + height: calc(100vh - 220px); + overflow-x: hidden; + overflow-y: scroll; +} + +.public-list-adv { + height: calc(100vh - 300px); + overflow-x: hidden; + overflow-y: scroll; +} + +.review-element { + height: calc(100vh - 110px); + overflow-x: hidden; + overflow-y: scroll; +} + +.review-list { + height: calc(100vh - 214px); + overflow-x: hidden; + overflow-y: scroll; +} + @media only screen and (max-width: 1500px) { .list-elements { - height: calc(100vh - 350px); + height: calc(100vh - 330px); + } + + .public-element { + height: calc(100vh - 204px); + overflow-x: hidden; + overflow-y: scroll; } + .public-list { + height: calc(100vh - 226px); + overflow-x: hidden; + overflow-y: scroll; + } + } @-webkit-keyframes blink-1 { @@ -236,4 +294,3 @@ th.drag { -moz-animation: blink-3 4s infinite ; /* Fx 5+ */ animation: blink-3 4s infinite ; /* IE 10+, Fx 29+ */ } - diff --git a/app/assets/stylesheets/flexbox.css b/app/assets/stylesheets/flexbox.css new file mode 100644 index 000000000..1f128871d --- /dev/null +++ b/app/assets/stylesheets/flexbox.css @@ -0,0 +1,156 @@ +.cards_flex { + display: flex; + display: -webkit-flex; + -webkit-justify-content: center; + -webkit-align-items: center; + justify-content: center; + align-items: center; +} + +.cards_ary { + width: 100%; + display: flex; + display: -webkit-flex; + justify-content: center; + -webkit-justify-content: center; + max-width: calc(95vw); +} + +.card--1 .card__img, .card--1 .card__img--hover { + background-image: url('../images/spectraviewer.png'); + object-fit: cover; +} + +.card--2 .card__img, .card--2 .card__img--hover { + background-image: url('../images/chemeln.jpg'); + object-fit: cover; +} + +.card--3 .card__img, .card--3 .card__img--hover { + background-image: url('../images/chemrepo.png'); + object-fit: cover; +} + +.card--4 .card__img, .card--4 .card__img--hover { + background-image: url('../images/chemscanner.png'); + object-fit: cover; +} + +.card__like { + width: 40px; + float: right; +} + +.card__clock { + width: 15px; + vertical-align: middle; + fill: #337ab7; +} +.card__time { + font-size: 18px; + font-weight: bold; + color: #337ab7; + vertical-align: middle; + margin-left: 5px; + +} + +.card__clock-info { + float: right; +} + +.card__img { + visibility: hidden; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + width: 100%; + height: 350px; + border-top-left-radius: 12px; + border-top-right-radius: 12px; + object-fit: cover; +} + +.card__info-hover { + position: absolute; + padding: 16px; + width: 100%; + opacity: 0; + top: 0; + text-align: start; +} + +.card__img--hover { + transition: 0.2s all ease-out; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + width: 100%; + position: absolute; + height: 350px; + border-top-left-radius: 12px; + border-top-right-radius: 12px; + top: 0; + object-fit: cover; +} +.card_u { + margin-right: 50px; + transition: all .2s cubic-bezier(0.175, 0.885, 0, 1); + background-color: #fff; + width: 25%; + position: relative; + border-radius: 12px; + overflow: hidden; + box-shadow: 20px 20px 10px -8px rgba(0, 0, 0,0.1) +} +.card_u:hover { + box-shadow: 0px 30px 18px -8px rgba(0, 0, 0,0.1); + transform: scale(1.10, 1.10); +} + +.card__info { + z-index: 2; + background-color: #fff; + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; + padding: 16px 24px 24px 24px; +} + +.card__title { + margin-top: 25px; + margin-bottom: 5px; +} + +.card__desc { + margin-top: 10px; + font-weight: bolder !important; + color: #337ab7; + position: relative; + opacity: 1 !important; + height: 300px; + width: 100%; + font-size: 0.8vw; + vertical-align: middle; + display: table-cell; +} + +.card__try { + position: relative; + z-index: 6; + display: flex; + display: -webkit-flex; +} + +.card_u:hover .card__img--hover { + height: 100%; + opacity: 0.1; +} + +.card_u:hover .card__info { + background-color: transparent; + position: relative; +} + +.card_u:hover .card__info-hover { + opacity: 1; +} diff --git a/app/assets/stylesheets/generic_element.scss b/app/assets/stylesheets/generic_element.scss index 18cd2295d..7091a47b9 100644 --- a/app/assets/stylesheets/generic_element.scss +++ b/app/assets/stylesheets/generic_element.scss @@ -404,3 +404,9 @@ $color-default: #777; opacity: 0.2; } } + +.generic_segments_repo { + padding: 10px; + background-color: white; + margin-bottom: 6px; +} diff --git a/app/assets/stylesheets/home.scss b/app/assets/stylesheets/home.scss index 59dae2bcf..a2932d222 100644 --- a/app/assets/stylesheets/home.scss +++ b/app/assets/stylesheets/home.scss @@ -1,10 +1,27 @@ -.omniauth-navitem { - padding-left: 24px; - - img { - position: absolute; - top: 6; - left: -15px; - height: 24px; +.div-table { + display: table; + border-spacing: 10px; + + .span-row { + display: table-row; + + span { + display: table-cell; + vertical-align: middle; } -} \ No newline at end of file + } +} +.small-p .ql-editor > p { + font-size: small; +} + +.expand-p .ql-editor > p { + margin-bottom: unset; +} + +iframe#home-tos { + width: 100%; + min-height: 80vh; + height: auto; + border: inherit; +} diff --git a/app/assets/stylesheets/howto.scss b/app/assets/stylesheets/howto.scss new file mode 100644 index 000000000..bab44f000 --- /dev/null +++ b/app/assets/stylesheets/howto.scss @@ -0,0 +1,111 @@ +// .howto { +.editor-stelle { + font-size: larger; + font-weight: bolder; + background-color: #eeeeee; +} + .editor-btn { + float: left; + margin: 20px 0px 20px 0px; + button { + height: 30px; + width: 60px; + margin-right: 10px; + } + } + .editor-btn-right { + float: right; + margin-bottom: 20px; + button { + height: 30px; + width: 60px; + margin-left: 10px; + } + } + .editor-field { + padding: 0px 100px 70px 100px; + border: darkcyan medium dotted; + label { + margin-top: 5px; + } + .title { + font-size: 1.6em; + font-weight: bold; + } + .drag-source { + cursor: move; + float: right; + margin-top: 5px; + margin-right: 5px; + } + &.static { + margin-bottom: 20px; + + .ql-editor { + padding: 0; + } + + .ql-hidden { + display: none; + } + } + + .image-container { + text-align: center; + + img { + max-width: 100%; + } + + p { + margin-bottom: 0; + } + } + } + .editor-field-drop-target { + height: 20px; + background-color: #eeeeee; + + .indicator { + height: 10px; + } + + &.can-drop { + .indicator { + border-bottom: 4px dashed lightgray; + } + } + + &.is-over { + .indicator { + border-bottom: 4px dashed #31708f; + } + } + } + + .editor-field-drop-add-field { + float: left; + margin-bottom: 20px; + + button { + height: 30px; + width: 60px; + margin-right: 10px; + } + } + .dropzone { + min-height: 50px; + width: 100%; + border: 3px dashed lightgray; + + .image-container { + margin: 15px; + } + + p { + text-align: center; + padding-top: 12px; + color: grey; + } + } +// } diff --git a/app/assets/stylesheets/navigation.scss b/app/assets/stylesheets/navigation.scss index fa53b3d6d..011923568 100644 --- a/app/assets/stylesheets/navigation.scss +++ b/app/assets/stylesheets/navigation.scss @@ -16,6 +16,13 @@ } } +.navig-smaller-font { + a.dropdown-toggle { + font-size: 70%; + } +} + + @media only screen and (min-width: 1366px) and (min-height: 500px) { .card-navigation { /* position: fixed; @@ -78,3 +85,7 @@ border-top: 1px solid #ccc; } } + +.navbar-guest { + display: inline; +} diff --git a/app/assets/stylesheets/news.scss b/app/assets/stylesheets/news.scss new file mode 100644 index 000000000..8e886aaa8 --- /dev/null +++ b/app/assets/stylesheets/news.scss @@ -0,0 +1,282 @@ +@import url('https://fonts.googleapis.com/css?family=EB+Garamond:400,700,700i,800,800i'); +@import url('https://fonts.googleapis.com/css?family=Spectral:400,700,700i'); + +$timelinecolor: #5A5A5A; // #337ab7; // #0079bf; +$boxbackgroundcolor: rgba(62,62,62, 0.1); + +.newseditor_review { + border: none; + font-family: 'Spectral', serif; + & .panel-heading { + background-color: white !important; + border: none; + font-weight: bolder; + & > span { + font-size: 2em; + font-weight: bolder; + } + } + & .panel-body { + background-color: white !important; + border: none; + max-height: 50vh; + overflow-Y: auto; + border-radius: 6px; + & > span { + font-size: 1.6em; + font-family: 'Spectral', serif; + } + & .ql-container .ql-editor > p > img { + max-width: 30vw; + } + } +} + +.news_time_box { + margin:0 0 0 10%; + // height: 630px; + // overflow: hidden; + padding: 0px 0px 40px 120px; + & > ul { + list-style-type: none; + margin: 0; + padding: 0; + position: relative; + transition: all 0.5s linear; + top: unset; + &:last-of-type{ + // top:80px + &:before { + top: unset; + } + } + &:before { + content: ""; + display: block; + width: 0; + // height: 100%; + height: 80%; + border:1px dashed $timelinecolor; + position: absolute; + // top:0; + top: 100px; + left:30px + } + & > li { + margin: 20px 60px 60px; + position: relative; + padding: 10px 20px; + background-color: $boxbackgroundcolor; + // background:rgba(255, 255, 255, 0.3); + // color:#fff; + border-radius: 10px; + box-shadow: 1px 1px 2px 0px rgba(0,0,0,0.5); + line-height: 20px; + // width: 35%; + & > span { + content: ""; + display: block; + width: 0; + height: 100%; + border: 1px solid $timelinecolor; + position: absolute; + top :0; + left: -30px; + &:before, &:after { + content: ""; + display: block; + width: 30px; + height: 30px; + border-radius: 50%; + background: $timelinecolor; + border:2px solid #fff; + position: absolute; + left: -15px + } + &:before { + top: -10px; + } + &:after { + top: 95%; + background-color: #eee; // aliceblue; + border: 2px solid $timelinecolor; + } + } + & .title { + font-weight: bolder; + font-size: 2rem; + background-color: unset; + line-height: 1.6; + } + & .readedit { + font-weight: normal; + float: right; + } + } + } + & .content { + font-weight: normal; + } + & .author { + margin-top: 10px; + text-transform: capitalize; + font-style: italic; + // text-align: right; + margin-right: 20px + } + & .created_at span{ + position: absolute; + left: -100px; + color: $timelinecolor; // #5A5A5A; + font-size:80%; + font-weight: bold; + } + & .created_at span:first-child{ + top:-6px; + left: -220px; + font-size: large; + font-weight: normal; + } + & .created_at span:last-child{top:94%} + +} + +// @import 'https://fonts.googleapis.com/css?family=Open+Sans:300,400'; +.news-preview-dialog { + display: block; + padding-left: 0px; + top: 45% !important; + width: 60vw !important; +} + +%reset { margin: 0; padding: 0; } +%flex { display: flex; justify-content: center; align-items: center; } + +@mixin animated($timein: null, $delay: null) { + transition: all $timein ease-in-out; + transition-delay: $delay; +} + +@mixin dimensions($width: null, $height: null) { width: $width; min-height: $height; } + +// $maincolor: #009688; +$maincolor: #337ab7; +// html{height: 100%;} +// body{ + +// @extend %flex; +// font-family: 'Open Sans', sans-serif; +// width: 100%; +// min-height: 100%; +// background: #009688; +// font-size: 16px; +// overflow: hidden; +// } + + +// *, *:before, *:after { +// box-sizing: border-box; +// } +.news-content{ + position: relative; + // animation: animatop 0.9s cubic-bezier(0.425, 1.140, 0.470, 1.125) forwards ; +} + +.news-card{ + @include dimensions( 580px, 100px); + padding: 20px; + border-radius: 3px; + background-color: white; + box-shadow: 0px 10px 20px rgba(0,0,0,0.2); + position: relative; + overflow: hidden; + &:after { + content: ''; + display: block; + width: 190px; + height: 300px; + // background: cadetblue; + background: #337ab7; + position: absolute; + animation: rotatemagic 0.75s cubic-bezier(0.425, 1.040, 0.470, 1.105) 1s both; + } +} + +.badgescard{ + padding: 10px 20px; + border-radius: 3px; + background-color: #ECECEC; + width: 560px; + box-shadow: 0px 10px 20px rgba(0, 0, 0, 0.2); + position: relative; + z-index: -1; + left: 10px; + bottom: 10px; + margin-bottom: 50px; + justify-content: flex-end; + @extend %flex; + animation: animainfos 0.5s cubic-bezier(0.425, 1.040, 0.470, 1.105) 0.75s forwards; + span { + font-size: 1em; + margin: 0px 2px; + opacity: 0.6; + } +} + +.firstinfo{ + @extend %flex; + flex-direction: row; + z-index:2; + position: relative; + img{ + // border-radius: 50%; + width: 120px; + height: 120px; + } + .profileinfo{ + padding: 0px 20px; + width: 100vw; + // h1{ font-size: 1.8em;} + h1{ font-size: 1.6em; font-weight: bolder; } + h3{ font-size: 1.2em; + color: $maincolor; + font-style: italic; + } + p.bio{ + padding: 10px 0px; + color: #5A5A5A; + line-height: 1.2; + font-style: initial; + } + } +} + +@keyframes animatop{ + 0%{ + opacity: 0; + bottom: -500px;} + 100%{ + opacity: 1; + bottom: 0px;} +} + +@keyframes animainfos{ + 0%{ + bottom: 30px;} + 100%{ + bottom: 0px;} +} + +@keyframes rotatemagic{ + 0%{ + opacity: 0; + transform: rotate(-0deg); + top: -24px; + left: -253px; + } + 100%{ + transform: rotate(-30deg); + top: -24px; + left: -78px; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/newsroom.css b/app/assets/stylesheets/newsroom.css new file mode 100644 index 000000000..0641ab1e8 --- /dev/null +++ b/app/assets/stylesheets/newsroom.css @@ -0,0 +1,607 @@ +@import url('https://fonts.googleapis.com/css?family=EB+Garamond:400,700,700i,800,800i'); +@import url('https://fonts.googleapis.com/css?family=Spectral:400,700,700i'); + +.chemotion_news { + font-size: 5rem; + font-weight: bold; + text-align: center; + font-family: 'EB Garamond', serif; + /* font-style: italic; */ + margin-bottom: 10px; + text-shadow: 4px 4px 4px #aaa; + border-top-color: black; + border-top-style: double; + border-bottom-color: black; + border-bottom-style: double; +} + +.required_style { + display: inline; + font-weight: normal; + color: #d9534f; + padding-left: 8px; + font-size: 1.2em; +} + +.newseditor, .required, .article_title, .article_content { + display: inline; + font-weight: normal; +} + +.newseditor .required { + color: #d9534f; + padding-left: 8px; + font-size: 1.2em; +} + +.newseditor .article_title { + font-size: 1.6em; + font-weight: bold; +} + +.newseditor .article_content { + color: #777777; + padding-left: 8px; + font-size: 1.2em; +} + +.newsreader_panel { + border: none; + font-family: 'Spectral', serif; +} + +.newsreader_panel .panel-heading { + background-color: white !important; + border: none; + font-weight: bolder; + /* height: 26vh; */ +} + +.newsreader_panel .panel-heading > h2 { + font-size: 2.6em; + font-weight: bolder; +} + +.newsreader_panel .panel-body { + background-color: white !important; + border: none; + /* height: calc(100vh - 390px); */ + overflow-Y: auto; + border-radius: 6px; +} + +.news_quillviewer { + display: flex; + flex-direction: column; +} +.news_quillviewer > span { + font-size: 1.6em; + font-family: 'Spectral', serif; +} +.news_quillviewer > div { + margin-left: auto; + margin-right: auto; +} +.news_quillviewer > div > img { + display: block; + max-height: 100%; + max-width: 100%; +} + +.newsreader_panel .panel-body .ql-container .ql-editor > p > img { + max-width: 30vw; +} + +.newsroom { + display: flex; + justify-content: center; + flex-direction: column; + max-width: 1000px; + margin: auto; +} + +.news_latest_block { + /* position: relative; */ + margin-bottom: 50px; + background-color: #FFF; + -webkit-box-shadow: 1px 1px 9px 0px rgba(0,0,0,0.75); + -moz-box-shadow: 1px 1px 9px 0px rgba(0,0,0,0.75); + box-shadow: 1px 1px 9px 0px rgba(0,0,0,0.75); + padding: 40px; + /* flex-grow: 2; */ + display: -webkit-flex; /* Safari */ + display: flex; + justify-content: flex-start; + -webkit-flex-direction: row; /* Safari 6.1+ */ + flex-direction: row; +} + +.news_latest { + position: relative; + height: 400px; + /* width: 900px; */ + margin-bottom: 50px; + background-color: #FFF; + -webkit-box-shadow: 1px 1px 9px 0px rgba(0,0,0,0.75); + -moz-box-shadow: 1px 1px 9px 0px rgba(0,0,0,0.75); + box-shadow: 1px 1px 9px 0px rgba(0,0,0,0.75); + padding: 50px; + /* flex-grow: 2; */ + display: -webkit-flex; /* Safari */ + display: flex; + justify-content: flex-start; + -webkit-flex-direction: row; /* Safari 6.1+ */ + flex-direction: row; +} + +.news_latest .badge { + position: absolute; + right: 50px; + bottom: 360px; + box-sizing: border-box; + padding-top: 12px; + background-color: #f1c40f; + width: 80px; + height: 80px; + color: #4B4B4B; + text-align: center; + border-radius: 50%; + -webkit-box-shadow: 10px 10px 50px 0px rgba(0, 0, 0, 0.75); + -moz-box-shadow: 10px 10px 50px 0px rgba(0, 0, 0, 0.75); + box-shadow: 10px 10px 50px 0px rgba(0, 0, 0, 0.75); +} + +.news_latest .badge_green { + position: absolute; + right: 50px; + bottom: 360px; + box-sizing: border-box; + padding-top: 12px; + background-color: #2ecc71; + width: 80px; + height: 80px; + color: #4B4B4B; + text-align: center; + border-radius: 50%; + -webkit-box-shadow: 10px 10px 50px 0px rgba(0, 0, 0, 0.75); + -moz-box-shadow: 10px 10px 50px 0px rgba(0, 0, 0, 0.75); + box-shadow: 10px 10px 50px 0px rgba(0, 0, 0, 0.75); +} + +.news_latest .news_image { + width: 60%; + max-width: 100%; + /* background-image: url('../images/chemeln.jpg'); */ + background-size: contain; + background-repeat: no-repeat; +} + +.news_latest .news_image > svg { + width: 90px !important; + height: 90px !important; +} + +.news_latest > img { + vertical-align: bottom; +} + +.news_latest .news_title { + font-size: 3rem; + font-weight: bolder; + color: #0079bf; + word-break: break-word; +} + +.news_author { + font-size: 1.4rem; + margin-top: 5px; + font-weight: normal; +} + +.news_author > img { + /* padding-top: 2px; */ + margin-left: 10px; + float: left; + height: 20px; + width: 20px; + border-radius: 50%; +} + +.heading_separator { + margin-top: 10px; + border: 1px solid #C3C3C3; +} + +.news_card { + border: none !important; + margin-right: 10px; + margin-bottom: 80px !important; + box-shadow: unset !important; +} + +.news_header { + width: auto; + /* height: 150px; */ + background-color: white !important; + /* text-align: center; */ + padding: 10px !important; + margin-left: 30px; + margin-right: 30px; +} + +.news_header > img { + height: 100px; + width: auto; +} + +.news_body { + /* min-height: 150px; */ + padding-right: 30px !important; + padding-left: 30px !important; +} + +.news_title { + font-size: 2.2rem; + font-weight: bolder; + color: #0079bf; + /* color: #4B4B4B; */ + word-break: break-word; +} + +.news_content { + text-align: justify; + font-size: 1.5rem; + line-height: 150%; + color: #4B4B4B; + max-height: 146px; + text-overflow: clip; + overflow: hidden; + white-space: pre-line; +} + +.news_content_card_s { + text-align: justify; + font-size: 1.5rem; + line-height: 150%; + color: #4B4B4B; + /* height: 100px; */ + max-height: 146px; + text-overflow: clip; + overflow: hidden; + white-space: pre-line; +} + +.news_card_content { + position: relative; +} + +.news_card_content .news_card_s { + width: 70vw; + min-height: 200px; + padding: 20px; + border-radius: 3px; + background-color: white; + box-shadow: 0px 10px 10px rgba(0, 0, 0, 0.2); + position: relative; + overflow: hidden; +} + +.news_card_content .news_card_s::before + { + content: ''; + display: block; + width: 190px; + height: 300px; + background: #e0e0e0; + position: absolute; + transform: rotate(-30deg); + top: -24px; + left: -78px; +} + +.card_info { + display: flex; + justify-content: center; + align-items: center; + flex-direction: row; + z-index: 2; + position: relative; +} + +.card_info .info { + padding: 0px 20px; + width: 100vw; +} + +.card_info .info > h1 { + font-size: 1.6em; + font-weight: bolder; + word-break: break-word; + margin-top: unset; + margin-bottom: unset; +} + +.card_info .info > span { + font-size: 0.9em; + margin: 0px 1px; + opacity: 0.8; +} + +.card_info .info .short_content { + max-height: 100px; +} + +.card_info .info .short_content::before { + background: linear-gradient(hsla(0,0%,100%,0.6),#fff); + content: ""; + height: 80px; + position: absolute; + left: 0px; + top: 240px; + width: 100%; +} + +.news_card_content .news_card_s .read { + position: absolute; + /* bottom: 10px; */ + /* right: 10px; */ + z-index: 2; + padding: 0px 20px; +} + +.news_card_badge_s { + padding: 10px 10px; + border-radius: 3px; + background-color: #ECECEC; + /* width: 240px; */ + width: calc(70vw - 20px); + box-shadow: 0px 10px 10px rgba(0, 0, 0, 0.2); + position: relative; + z-index: -1; + left: 10px; + bottom: 0px; + margin-bottom: 50px; + justify-content: flex-end; + text-align: right; +} + +.news_card_badge_s > span { + font-size: 0.9em; + margin: 0px 1px; + opacity: 0.6; + font-style: italic; +} + +.news_card_badge_s > img { + /* padding-top: 3px; + float: left; */ + height: auto; + width: 15px; +} + +.news_column { + display: flex; + justify-content: center; +} + +.news_badge { + position: relative; + width: 100px; + display: inline-block; + margin: 40px; +} +.news_badge .title { + font-weight: bold; + font-size: 0.8em; + position: absolute; + top: -28px; + border-radius: 8px 8px 0 0; + text-align: center; + width: 100%; + background: red; + padding: 10px 0; +} +.news_badge .subtitle { + position: absolute; + font-size: 0.8em; + width: 100%; + text-align: center; + color: white; + position: absolute; + top: 100px; +} +.news_badge .subtitle:after { + content: "★ ★ ★"; + display: block; + font-size: .4em; + position: relative; + margin: 15px 0 0; + transition: all .3s; +} +.news_badge:hover .subtitle:after { + word-spacing: 30px; +} + +.howto_page { + margin: auto; + height: calc(90vh); + overflow: auto; +} + +.red .title { + background: #DC514E; +} +.red .subtitle:after { + color: #DC514E; +} + +.green .title { + background: #2ecc71; +} +.green .subtitle:after { + color: #2ecc71; +} + +.gray .title { + background: #95a5a6; +} +.gray .subtitle:after { + color: #95a5a6; +} + +.yellow .title { + background: #f1c40f; +} +.yellow .subtitle:after { + color: #f1c40f; +} + +.blue .title { + background: #3498db; +} +.blue .subtitle:after { + color: #3498db; +} + +.news_pager { + /* position: absolute; */ + right: 60px; + bottom: 60px; + box-sizing: border-box; + padding-top: 15px; + background-color: #DC514E; + width: 60px; + height: 60px; + color: white; + text-align: center; + border-radius: 50%; + -webkit-box-shadow: 0px 0px 5px 5px rgba(241, 147, 147, 0.75); + -moz-box-shadow: 0px 0px 5px 5px rgba(241, 147, 147, 0.75); + box-shadow: 0px 0px 5px 5px rgba(241, 147, 147, 0.75); +} + +/* common */ +.ribbon { + width: 150px; + height: 150px; + overflow: hidden; + position: absolute; + z-index: 10; +} +.ribbon::before, +.ribbon::after { + position: absolute; + z-index: -1; + content: ''; + display: block; + border: 5px solid #2980b9; +} +.ribbon span { + position: absolute; + display: block; + width: 225px; + padding: 15px 0; + background-color: #3498db; + box-shadow: 0 5px 10px rgba(0,0,0,.1); + color: #fff; + font: 700 18px/1 'Lato', sans-serif; + text-shadow: 0 1px 1px rgba(0,0,0,.2); + text-transform: uppercase; + text-align: center; +} + +/* top left*/ +.ribbon-top-left { + top: -10px; + left: -10px; +} +.ribbon-top-left::before, +.ribbon-top-left::after { + border-top-color: transparent; + border-left-color: transparent; +} +.ribbon-top-left::before { + top: 0; + right: 0; +} +.ribbon-top-left::after { + bottom: 0; + left: 0; +} +.ribbon-top-left span { + right: -25px; + top: 30px; + transform: rotate(-45deg); +} + +/* top right*/ +.ribbon-top-right { + top: -10px; + right: -10px; +} +.ribbon-top-right::before, +.ribbon-top-right::after { + border-top-color: transparent; + border-right-color: transparent; +} +.ribbon-top-right::before { + top: 0; + left: 0; +} +.ribbon-top-right::after { + bottom: 0; + right: 0; +} +.ribbon-top-right span { + left: -25px; + top: 30px; + transform: rotate(45deg); +} + +/* bottom left*/ +.ribbon-bottom-left { + bottom: -10px; + left: -10px; +} +.ribbon-bottom-left::before, +.ribbon-bottom-left::after { + border-bottom-color: transparent; + border-left-color: transparent; +} +.ribbon-bottom-left::before { + bottom: 0; + right: 0; +} +.ribbon-bottom-left::after { + top: 0; + left: 0; +} +.ribbon-bottom-left span { + right: -25px; + bottom: 30px; + transform: rotate(225deg); +} + +/* bottom right*/ +.ribbon-bottom-right { + bottom: -10px; + right: -10px; +} +.ribbon-bottom-right::before, +.ribbon-bottom-right::after { + border-bottom-color: transparent; + border-right-color: transparent; +} +.ribbon-bottom-right::before { + bottom: 0; + left: 0; +} +.ribbon-bottom-right::after { + top: 0; + right: 0; +} +.ribbon-bottom-right span { + left: -25px; + bottom: 30px; + transform: rotate(-225deg); +} diff --git a/app/assets/stylesheets/omniauth.scss b/app/assets/stylesheets/omniauth.scss new file mode 100644 index 000000000..a4f0c1229 --- /dev/null +++ b/app/assets/stylesheets/omniauth.scss @@ -0,0 +1,23 @@ +.omniauth-btn { + width: 20vh; + text-align: left; + img { + position: absolute; + text-align: left; + height: 2vh; + width: auto; + } +} + +.login-options { + display: flex; + justify-content: center; + margin-bottom: 10px; + a { + width: 100%; + img { + width: auto; + height: 3vh; + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/quill_viewer.scss b/app/assets/stylesheets/quill_viewer.scss index 9b91844e4..3ea78a27b 100644 --- a/app/assets/stylesheets/quill_viewer.scss +++ b/app/assets/stylesheets/quill_viewer.scss @@ -5,4 +5,11 @@ .ql-editor { -webkit-user-select: text; user-select: text; -} \ No newline at end of file +} + +.ql-disabled { + .ql-hidden { + display: none; + } +} + diff --git a/app/assets/stylesheets/reaction.scss b/app/assets/stylesheets/reaction.scss index b2575e907..0bc2961f0 100644 --- a/app/assets/stylesheets/reaction.scss +++ b/app/assets/stylesheets/reaction.scss @@ -222,3 +222,7 @@ table.reaction-scheme-solvent { //from {top: 0px;} from { background-color: aliceblue;} } + +.publishReactionModal { + margin: unset !important; +} diff --git a/app/assets/stylesheets/repo_home.scss b/app/assets/stylesheets/repo_home.scss new file mode 100644 index 000000000..2642dd704 --- /dev/null +++ b/app/assets/stylesheets/repo_home.scss @@ -0,0 +1,1005 @@ +$searchcolor: #dddddd; +$statisticcolor: #3a9fd3; +$topbgcolor: #f5f5f5; +$bs-primarycolor: #337ab7; +$bs-defaultcolor: #777777; +$bs-infocolor: #5bc0de; +$bs-warningcolor: #f0ad4e; +$bs-successcolor: #5cb85c; + +@mixin viewer-modal { + height: 95%; + top: 52% !important; + margin: unset !important; +} + +.file-viewer-modal { + @include viewer-modal; + width: 98vw !important; + .modal-content { + height: 95%; + } +} + +.structure-viewer-modal { + @include viewer-modal; + width: 60vw !important; + .modal-content { + height: calc(100% - 60px); + } +} + +.repo-xvial-info { + .env { + padding: 0px 2px 0px 2px; + background-color: white; + color: $bs-primarycolor; + } + .youtube { + color: red; + font-size: 150%; + } +} + +.author-modal-dialog { + display: block; + padding-left: 0px; + top: 40% !important; + width: 80vw !important; +} + +.aff-line { + .form-group { + margin-bottom: unset !important; + } +} + +.repo-pub-sample-header { + display: flex; + align-items: center; + .repo-pub-title { + font-size: 150%; + } + .repo-public-user-comment { + margin-left: auto; + } +} + +.repo-pub-list-icons { + display: inline-flex; + align-items: center; +} + +@mixin xvial-elem-base { + margin: 0px 4px 0px 0px; +} + +.xvial-pub-elem { + display: inline-block; + span { + border-radius: 4px; + display: flex; + justify-content: center; + color: $bs-primarycolor; + padding: 2px 0px 2px 6px; + border: 2px solid $bs-primarycolor; + background-color: white; + div { + font-size: 50%; + display: contents; + } + button { + @include xvial-elem-base; + padding: 0px; + } + a { + @include xvial-elem-base; + cursor: pointer; + } + } + span i { + @include xvial-elem-base; + } +} + +@mixin xvial-span-base { + justify-content: center; + font-size: 80%; + width: 22px; + height: 22px; + padding: 2px; + border-radius: 3px; + background-color: white; + color: orange; // $bs-defaultcolor; + border: 2px solid orange; // $bs-defaultcolor; +} + +.xvial-span { + @include xvial-span-base; + display: none; +} + +.xvial { + display: flex; + color: $bs-primarycolor; +} + +.xvial-com { + display: inline-flex; + border: 2px solid $bs-primarycolor; +} + +.repo-registed-compound-desc { + color: $bs-primarycolor; + font-style: italic; + i { + color: black; + cursor: pointer; + } +} + +.repo-home { + display: block; + max-width: 1200px; + margin: auto; +} + +.card-well { + background-color: #f5f5f5; + border-radius: 6px; + height: 36vh; //auto; //calc(80vh - 190px); + padding: 10px 0px; +} + +.card-nfdi4chem { + flex-direction: column; + justify-content: center; + align-items: center; + background-color: white; + > h3 { + text-align: center; + margin-bottom: 30px; + padding: 0px 10px; + } + > h4 { + text-align: center; + margin-bottom: 30px; + padding: 0px 10px; + } +} + +.card-latest { + flex-direction: column; + justify-content: center; + align-items: center; + background-color: white; +} + +.card-well-competition { + @extend .card-well; + display: flex; + margin: 40px 20px 20px 20px; + border-radius: 0px; + .carl-spt { + flex: 1; + display: flex; + .carousel-inner .active.left { + top: 0%; + } + .carousel-inner .next { + top: 0%; + } + .carousel-inner .prev { + top: 0%; + } + } + + .carl-spt-item { + text-align: center; + height: 100%; + width: 100%; + a { + cursor: pointer; + display: flex; + align-items: center; + flex: 1; + } + } + + .carl-spt-item .img { + display: flex; + flex-direction: column; + align-items: center; + width: 480px; + height: 100%; + } + + .carl-spt-item .caption { + position: sticky; + font-size: 1vmax; + color: black; + text-shadow: unset; + padding-bottom: unset; + left: 0px; + } + + .carl-spt-item-s { + height: 220px; + text-align: center; + } + + .carl-spt-item-s .caption { + left: 15%; + right: 15%; + padding-bottom: 0px; + bottom: 0px; + } + + img { + width: 100%; + } + + p { + bottom: -45px; + color: black; + position: relative; + text-align: justify; + text-shadow: none; + } + + .carousel-control.left, + .carousel-control.right { + background-image: none !important; + color: #e7e7e7; + } + + .italic-desc { + font-size: 1.4rem; + font-style: italic; + } +} + +.thumbnail-spt img { + width: 25%; +} + +.icon-chemotion { + width: 20%; +} + +.icon-nfdi4chem { + width: 80% !important; +} + +.visibility-impact { + left: 30px; + width: 300px !important; +} + +.icon-molecule-archive { + margin: 10px; + background-color: rgb(12, 61, 118); +} + +.icon-molecule-archive2 { + width: 3vw; + height: auto; + background-color: rgb(12, 61, 118); +} + +.chem-finder { + left: 10%; + top: 10px; + + img { + width: 90%; + } +} + +.bot-navbar { + right: 0px; + left: 0px; + bottom: 0px; + z-index: 1030; + background-color: #efefef; +} + +.card-partners { + background-color: #f5f5f5; + .partner-heading { + background-color: #f5f5f5; + border-radius: 6px; + margin: 20px 5px 0; + padding: 5px; + text-align: center; + } + + .partner-row { + display: flex; + justify-content: center; + overflow-x: auto; + padding-top: 30px; + } + + .partners-info { + height: 250px; + position: relative; + display: table-cell; + margin: 5px; + border: 0px; + background-color: #f5f5f5; + + img { + display: block; + margin: auto; + max-height: 90%; + max-width: 90%; + opacity: 1; + z-index: 1; + } + + &:hover img { + opacity: 0.2; + } + + &:hover { + border: 2px solid #ddd; + } + + &:hover .info { + opacity: 1; + } + + .info { + top: 0%; + left: 0%; + opacity: 0; + position: absolute; + padding: 5px; + margin: 5px; + text-align: justify; + } + } +} + +.repo-about { + display: block; +} + +.card-icons { + div { + height: 100px; + position: relative; + } + + img { + max-height: 100%; + max-width: 100%; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + } +} + +.card-well-about { + background-color: #f5f5f5; + border: 1px solid #e3e3e3; + border-radius: 6px; + padding: 30px; + text-align: center; +} + +.panel-analyses-public { + margin: 2px !important; +} + +.carl-sample svg { + max-width: 100%; + max-height: 500px; + height: 220px; + width: 100%; +} + +.reaction-scheme-word { + font-size: smaller; + color: $bs-primarycolor; +} + +.move-embargo-dialog { + display: block; + padding-left: 0px; + top: 45% !important; + width: 40vw !important; +} + +.table-scroll { + overflow: auto; + max-height: calc(25vh); + table { + width: 100%; + thead tr th { + position: sticky; + top: 0; + background: #eee; + } + tbody { + white-space: pre-line; + } + } +} + +.home-adv-search { + display: flex; + align-items: center; + div { + .o-author { + min-width: 120px; + } + .o-name { + min-width: 200px; + } + .Select-control { + border-radius: unset; + } + } + .btns-grp { + background-color: $searchcolor; + border: thin solid $searchcolor; + button { + background-color: $searchcolor; + border-color: $searchcolor; + border-radius: unset; + } + } +} + +.home-search { + margin-left: unset !important; + background-color: $searchcolor; + button { + background-color: $searchcolor; + } +} + +.clip-copy { + display: inline; + word-break: break-all; +} + +.repo-top-row { + display: flex; + align-items: stretch; + background-color: $topbgcolor; + border-radius: 6px; +} + +.repo-welcome { + max-width: 2000px; + margin: auto !important; + > div:nth-child(2) { + background-color: $topbgcolor; + > h2 { + text-align: center; + } + } +} + +.repo-molecule-archive { + @extend .card-well; + display: flex; + flex-direction: column; + padding-bottom: 10px; + border-radius: 0px; + > div { + margin-bottom: 10px; + display: flex; + align-items: center; + } + > div:nth-child(2) { + background-color: white; + padding: 10px; + border: 1px solid $statisticcolor; + border-bottom-width: 6px; + } + > div:not(:last-child) { + flex: 1; + justify-content: center; + div { + width: 100%; + .rl { + text-align: center; + font-size: 2rem; + font-weight: bold; + } + .rr { + button { + padding: unset; + } + .cnt { + font-size: 2.5rem; + font-weight: bold; + } + } + } + } + > div:last-child { + flex: 2; + div:first-child { + display: flex; + justify-content: center; + } + div:last-child { + font-size: 1.5rem; + display: block; + } + } +} + +.repo-intro { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + h1 { + text-align: center; + } + .row-bottom { + display: flex; + align-items: center; + div:first-child { + padding-right: unset; + } + div:last-child { + padding-left: unset; + } + } + .container { + display: flex; + flex-direction: column; + .row { + display: flex; + align-items: center; + flex: 1; + .heading { + font-size: 1.8vmax; + } + .list { + font-size: 1vmax; + } + } + .row .even { + flex: 1; + } + .row .even .row { + border: 1px solid #e7e7e7; + background-color: #e7e7e7; + h2 { + font-weight: bolder; + text-align: center; + } + .info { + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + .cnt { + font-size: 180%; + } + } + } + } +} + +.repo-statistic { + @extend .card-well; + padding-bottom: 10px; + > div { + flex: 1; + h3 { + text-align: center; + } + } + .elem-info { + border-bottom-width: 6px; + padding: unset; + border-color: $statisticcolor; + margin-bottom: 10px; + display: flex; + align-items: center; + flex: 1; + border-radius: 0px; + .dtl { + background-color: unset; + color: unset; + border: none; + text-align: center; + padding: 0px; + } + .dtl .rl { + line-height: normal; + font-weight: bolder; + display: inline; + i { + font-size: 5vmin; + } + .tit { + font-size: 1.8vmin; + } + .i-ext { + font-size: 4vmin; + margin-bottom: 6px; + } + } + .dtl .rr { + text-align: left; + font-size: 1.5vmin; + .tit { + font-size: 14px; + font-weight: 400; + button { + padding: unset; + } + } + .cnt { + font-size: 3vmin; + font-weight: bolder; + } + .italic-desc { + font-style: italic; + font-size: 90%; + } + } + } +} + +.repo-analysis-header { + display: inline-block; + width: 100%; + .preview { + vertical-align: top; + width: 125px; + float: right; + position: relative; + img { + height: 100%; + width: 100%; + } + .preview-table { + border: 1px solid transparent; + } + .preview-table:hover { + border: 1px solid $statisticcolor; + } + .spectra { + position: absolute; + top: 80%; + left: 76%; + } + } + .abstract { + display: inline-block; + width: calc(100% - 125px); + .lower-text { + .sub-title { + font-size: 14px; + } + } + .desc { + margin-bottom: 4px; + .clip-copy { + word-break: normal; + } + } + } +} + +.repo-analysis-listgroup { + border: none !important; + background-color: transparent !important; + padding-left: unset !important; +} + +.pub-info-dialog { + display: block; + padding-left: 0px; + top: 45% !important; + height: 40vh !important; + width: 40vw !important; +} + +.max_tooltip { + .tooltip-inner { + max-width: 30vw; + } +} + +.tooltip_list { + padding-inline-start: 20px; + margin-bottom: unset; +} + +.tooltip_list_paragraph { + ul { + padding-inline-start: 20px; + } + p { + margin-bottom: unset; + } +} + +@mixin btn-xvial-base { + border: none !important; + padding: 0px 0px 0px 5px !important; + font-size: 80% !important; +} + +.btn-xvial-none { + i { + color: unset; + } +} + +.btn-xvial-data { + display: flex; + i { + color: $bs-primarycolor; + } + span { + border: 1px solid $bs-primarycolor; + background-color: white; + color: $bs-primarycolor; + border-radius: 3px; + .base { + @include btn-xvial-base; + } + } +} + +.label-xvial-data { + display: inline-block; + cursor: pointer; +} + +.label-xvial-data span { + border: 1px solid $bs-primarycolor; + background-color: white; + color: $bs-primarycolor; +} + +.wrap-ring { + font-size: 1.6em; +} + +.animation-ring { + position: relative; + z-index: 0; + padding: 0px 8px 0px 8px !important; + font-size: 1.6em !important; + border: none !important; + background-color: unset !important; + margin: 3px; +} + +.animation-ring::before { + content: ''; + border-radius: 100%; + border: 0px solid $bs-primarycolor; + position: absolute; + z-index: -1; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + animation: ring 2s linear 30 both; +} + +@keyframes ring { + from { + border-width: unset; + width: 0px; + height: 0px; + opacity: 1; + } + to { + border-width: 3px; + width: 150%; + height: 150%; + opacity: 0; + } +} + +.bs_tooltip { + .tooltip-inner { + max-width: 200px; + } +} + +@mixin home-list-layout { + background-color: white; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2); + margin-top: 15px; + padding: 5px; +} + +.home_reaction { + @include home-list-layout; + border-radius: 8px; +} + +.home_archive { + @include home-list-layout; + display: inline-flex; + width: 100%; + .svg_border { + border-right: 1px lightgrey solid; + } + .content { + display: inline-flex; + flex-direction: column; + width: 100%; + .info { + max-height: 100%; + height: 100%; + .border { + display: flex; + padding: 0px 15px 5px 15px; + .dl { + font-weight: bold; + width: 25%; + } + .dr { + width: 75%; + } + } + } + } +} + +.home_wrapper { + display: flex; + div:last-child { + border: none; + } +} + +.home_wrapper_item { + flex: 1 20%; + text-align: center; + border-right: 1px lightgrey dashed; + min-height: 60px; + display: flex; + flex-direction: column; + div:first-child { + color: rgb(141, 140, 140); + max-height: 20px; + } + .item_xvial { + display: flex; + justify-content: center; + } +} + +.home_wrapper_item > div { + flex: 1 100%; + text-align: center; + align-items: center; +} + +.list_focus_off { + cursor: pointer; + border: 1px solid transparent; +} + +.list_focus_on { + cursor: pointer; + border: 1px #337ab7 solid; + background-color: #f0f8ff !important; +} + +.layout_svg_reaction > svg { + height: 120px; + max-height: unset; + width: 90%; + margin-left: 10px; +} + +.layout_svg_molecule > svg { + height: 90px; + width: 90px; +} + +.archive_svg_molecule > svg { + height: 140px; + width: 140px; +} + +.panel-cite { + border-color: white !important; + .panel-heading { + background-color: unset !important; + padding: unset !important; + border-color: white !important; + } +} + +.date_info { + display: inline-flex; + flex-direction: column; + margin-right: 8px; + .updated { + font-size: 0.6em; + text-align: right; + } +} + +.repo-quill-viewer { + .ql-editor { + cursor: inherit; + padding: unset; + } +} + +.repo-ma-panel { + span { + display: flex; + justify-content: center; + margin-right: 4px; + } + a { + @include xvial-elem-base; + cursor: pointer; + background-color: white; + padding: 0px 6px; + } + span i { + @include xvial-elem-base; + } +} + +.repo-public-sample-info { + h5 { + margin-top: 4px; + margin-bottom: unset; + display: flex; + align-items: center; + } + h5 .btn-link { + padding: unset; + } +} + +.repo-public-sample-rel { + button { + padding: 0px 0px; + } + button i { + font-size: 1.5em; + } +} + +.repo-btn-success span { + background-color: $bs-successcolor !important; + color: white !important; +} + +.repo-btn-success span:hover { + background-color: #19b5fe !important; + color: black !important; +} + +.new-version-btn-disabled, +.new-version-btn-disabled:hover { + color: #fff !important; + background-color: #5cb85c !important; + border-color: #4cae4c !important; + + opacity: 0.65; + box-shadow: none; + cursor: not-allowed; +} + +.version-dropdown-item { + font-size: small; +} diff --git a/app/assets/stylesheets/search.scss b/app/assets/stylesheets/search.scss index 7d4dbd240..7acc1af7b 100644 --- a/app/assets/stylesheets/search.scss +++ b/app/assets/stylesheets/search.scss @@ -19,6 +19,10 @@ position: relative; z-index: 2; } + + .button-caret { + border-radius: unset; + } } .list-group-item-wrap { diff --git a/app/assets/stylesheets/spectra.scss b/app/assets/stylesheets/spectra.scss index 28a3b8888..956445981 100644 --- a/app/assets/stylesheets/spectra.scss +++ b/app/assets/stylesheets/spectra.scss @@ -105,3 +105,8 @@ .qc-detail-panel-title{ font-size: 12px !important; } + +#nmrium_wrapper { + width: 100%; + height: 80vh; +} \ No newline at end of file diff --git a/app/assets/stylesheets/switch.scss b/app/assets/stylesheets/switch.scss index d3db4f3ec..d9ba6518d 100644 --- a/app/assets/stylesheets/switch.scss +++ b/app/assets/stylesheets/switch.scss @@ -85,6 +85,91 @@ } } +.switchb { + position: relative; + display: inline-block; + box-sizing: border-box; + width: 44px; + height: 22px; + line-height: 20px; + vertical-align: middle; + border-radius: 20px 20px; + border: 1px solid #ccc; + background-color: #5cb85c; + cursor: pointer; + transition-timing-function: ease; + + &-inner { + color:#fff; + top:4px; + font-size: 14px; + position: absolute; + left: 25px; + } + + &:after{ + position: absolute; + width: 18px; + height: 18px; + left: 3%; + top:4px; + border-radius: 50% 50%; + background-color: #ffffff; + content: " "; + cursor: pointer; + transform: scale(1); + transition-timing-function: ease; + animation-duration: 0.3; + animation-name: switchOff; + } + + &:hover:after{ + transform: scale(1.1); + animation-name: switchOn; + } + + &:focus { + outline: none; + } + + &-checked{ + background-color: #5bc0de; + .switchb-inner { + left: 10px; + } + &:after{ + left: 77%; + } + } + + &-disabled{ + cursor: no-drop; + background: #ccc; + border-color:#ccc; + + &:after{ + background: #9e9e9e; + animation-name: none; + cursor: no-drop; + } + + &:hover:after{ + transform: scale(1); + animation-name: none; + } + } + + &-label { + display: inline-block; + line-height: 20px; + font-size: 14px; + padding-left: 10px; + vertical-align: middle; + white-space: normal; + pointer-events: none; + user-select: text; + } +} @keyframes switchOn { 0% { transform: scale(1); diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0da4677ef..a4e3e9fa9 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,6 +2,7 @@ class ApplicationController < ActionController::Base # Prevent CSRF attacks by raising an exception. # For APIs, you may want to use :null_session instead. protect_from_forgery with: :exception + # before_action :store_user_location!, if: :storable_location? before_action :authenticate_user! protect_from_forgery with: :null_session, if: Proc.new { |c| c.request.format == 'application/json' } @@ -11,13 +12,12 @@ class ApplicationController < ActionController::Base def configure_permitted_parameters devise_parameter_sanitizer.permit(:sign_up, keys: [ - :email, :first_name, :last_name, :name_abbreviation, :omniauth_provider, :omniauth_uid, + :email, :first_name, :last_name, :name_abbreviation, :orcid, :provider, :uid, affiliations_attributes: [ :country, :organization, - :department, - :group - ], + :department + ] ]) devise_parameter_sanitizer.permit(:sign_in) do |u| u.permit(:login, :password, :remember_me) @@ -27,4 +27,12 @@ def configure_permitted_parameters :email ]) end + + # def storable_location? + # request.get? && is_navigational_format? && !devise_controller? && !request.xhr? + # end + + # def store_user_location! + # store_location_for(:user, ) + # end end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index fd7d6ee41..8bace407d 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,6 +1,6 @@ class PagesController < ApplicationController skip_before_action :authenticate_user!, only: [ - :home, :about, :chemscanner, :chemspectra, :chemspectra_editor + :home, :about, :directive, :root_page ] before_action :fetch_affiliations, only: [:affiliations, :update_affiliations] before_action :build_affiliation, only: [:affiliations, :update_affiliations] @@ -15,6 +15,8 @@ def welcome; flash.clear end + def mydb; end + def editor; end def sfn_cb @@ -31,13 +33,27 @@ def sfn_cb end end + def root_page + render layout: 'root_layout' + end + + def directive; end + + def settings; end + + def tokens + @origin = params[:origin] + aks = AuthenticationKey.where(user_id: current_user, role: "gate in") + @fqdns = aks.pluck :fqdn + end + def update_user @user = current_user @user.counters['reactions'] = params[:reactions_count].to_i @user.reaction_name_prefix = params[:reaction_name_prefix] if @user.save flash['success'] = 'User settings is successfully saved!' - redirect_to root_path + redirect_to main_app.root_url else flash.now['danger'] = 'Not saved! Please check input fields.' render 'user' @@ -62,6 +78,31 @@ def update_profiles end end + def update_orcid + @profile = current_user.profile + orcid = params['data_orcid'] + result = Chemotion::OrcidService.record_person(orcid) + + if result.nil? + flash.now['danger'] = 'ORCID does not exist! Please check.' + return render 'settings' + elsif result&.person&.given_names&.casecmp(current_user.first_name)&.zero? && + result&.person&.family_name&.casecmp(current_user.last_name)&.zero? + data = @profile.data || {} + data['ORCID'] = orcid + if @profile.update(data: data) + flash['success'] = 'ORCID is successfully saved!' + redirect_to root_path + else + flash.now['danger'] = 'Not saved! Please check input fields.' + return render 'settings' + end + else + flash.now['danger'] = 'Name could not be matched to the name of this ORCID ' + orcid + ' (family_name: ' + result&.person&.family_name + ', given_name: ' + result&.person&.given_names + '). Please check.' + return render 'settings' + end + end + def affiliations end @@ -98,6 +139,31 @@ def update_affiliations redirect_to pages_affiliations_path end + def update_orcid + @profile = current_user.profile + orcid = params['data_orcid'] + result = Chemotion::OrcidService.record_person(orcid) + + if result.nil? + flash.now['danger'] = 'ORCID does not exist! Please check.' + return render 'settings' + elsif result&.person&.given_names&.casecmp(current_user.first_name)&.zero? && + result&.person&.family_name&.casecmp(current_user.last_name)&.zero? + data = @profile.data || {} + data['ORCID'] = orcid + if @profile.update(data: data) + flash['success'] = 'ORCID is successfully saved!' + redirect_to root_path + else + flash.now['danger'] = 'Not saved! Please check input fields.' + return render 'settings' + end + else + flash.now['danger'] = 'Name could not be matched to the name of this ORCID ' + orcid + ' (family_name: ' + result.person.family_name + ', given_name: ' + result.person.given_names + '). Please check.' + return render 'settings' + end + end + private def build_affiliation @@ -114,13 +180,13 @@ def fetch_affiliations def affiliation_params params.require(:affiliation).permit( :id, :_destroy, - :country, :organization, :department, :group, + :country, :organization, :department, :from, :to, :from_month, :to_month ) end def sliced_affiliation_params - affiliation_params.slice(:country, :organization, :department, :group) + affiliation_params.slice(:country, :organization, :department) end def affiliations_params @@ -128,13 +194,13 @@ def affiliations_params :utf8, :_method, :authenticity_token, :commit, affiliations: [ :id, :_destroy, - # :country, :organization, :department, :group, + # :country, :organization, :department, :from, :to, :from_month, :to_month ] ) end def profile_params - params.require(:profile).permit(:show_external_name, :curation) + params.require(:profile).permit(:show_external_name, :curation, :data_orcid) end end diff --git a/app/controllers/users/omniauth_controller.rb b/app/controllers/users/omniauth_controller.rb index d2111eee1..26daf43b0 100644 --- a/app/controllers/users/omniauth_controller.rb +++ b/app/controllers/users/omniauth_controller.rb @@ -1,84 +1,95 @@ # frozen_string_literal: true -class Users::OmniauthController < Devise::OmniauthCallbacksController - def github - if user_signed_in? - current_user.link_omniauth(auth.provider, auth.uid) - redirect_to root_path - else - email = auth.info.email - first_name = auth.info.name.split[0..-2].join(' ') - last_name = auth.info.name.split.last +module Users + class OmniauthController < Devise::OmniauthCallbacksController + PROVIDER_GITHUB = 'github' - @user = User.from_omniauth(auth.provider, auth.uid, email, first_name, last_name) + def github + auth_handler + end - if @user.persisted? - sign_in_and_redirect @user, event: :authentication + def orcid + auth_handler + end + + def openid_connect + auth_handler + end + + def shibboleth + auth_handler + end + + protected + + def auth + request.env['omniauth.auth'] + end + + def first_name + if auth&.provider == PROVIDER_GITHUB + auth&.info&.name.split[0..-2].join(' ') else - session['devise.omniauth.data'] = { - :provider => auth.provider, - :uid => auth.uid, - :email => email, - :first_name => first_name, - :last_name => last_name - } - redirect_to new_user_registration_url + auth&.info&.first_name end end - end - def orcid - if user_signed_in? - current_user.link_omniauth(auth.provider, auth.uid) - redirect_to root_path - else - @user = User.from_omniauth(auth.provider, auth.uid, auth.info.email, auth.info.first_name, auth.info.last_name) - - if @user.persisted? - sign_in_and_redirect @user, event: :authentication + def last_name + if auth&.provider == PROVIDER_GITHUB + name.split&.last else - affiliation = auth && auth.info && auth.info.employments && auth.info.employments.length > 0 && auth.info.employments[0] - session['devise.omniauth.data'] = { - :provider => auth.provider, - :uid => auth.uid, - :email => auth.info.email, - :first_name => auth.info.first_name, - :last_name => auth.info.last_name, - :affiliation => affiliation - } - redirect_to new_user_registration_url + auth&.info&.last_name end end - end - def openid_connect - if user_signed_in? - current_user.link_omniauth(auth.provider, auth.uid) - redirect_to root_path - else - email = auth.info.email - first_name = auth.info.first_name - last_name = auth.info.last_name + def email + auth&.info&.email + end - @user = User.from_omniauth(auth.provider, auth.uid, email, first_name, last_name) + def affiliation + {} + # affiliation['organization'] = Swot.school_name(email) + end + + def name_abbreviation + (first_name&.first || '') + (last_name&.first || '') + end + def providers + provider = {} + provider[auth.provider] = auth.uid + provider + end + + def auth_signup + @user = User.from_omniauth(auth.provider, auth.uid, email, first_name, last_name) if @user.persisted? sign_in_and_redirect @user, event: :authentication else - session['devise.omniauth.data'] = { - :provider => auth.provider, - :uid => auth.uid, - :email => email, - :first_name => first_name, - :last_name => last_name - } + session_handler redirect_to new_user_registration_url end end - end - protected - def auth - request.env['omniauth.auth'] + def session_handler + session['devise.omniauth.data'] = { + provider: auth.provider, + uid: auth.uid, + email: email, + first_name: first_name, + last_name: last_name, + name_abbreviation: name_abbreviation, + affiliation: affiliation, + } + end + + def auth_handler + if user_signed_in? + current_user.link_omniauth(auth.provider, auth.uid) + redirect_to root_path + else + auth_signup + end + end end -end +end \ No newline at end of file diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index 052ac4f29..7c4eae839 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -1,53 +1,49 @@ -class Users::RegistrationsController < Devise::RegistrationsController -# before_action :configure_sign_up_params, only: [:create] -# before_action :configure_account_update_params, only: [:update] - - # GET /resource/sign_up - # def new - # super - # end - - def new - build_resource({}) - - @affiliation = resource.affiliations.build - - # try to get the data from the oauth provider from the session - if data = session['devise.omniauth.data'] - resource.email = data['email'] if resource.email.blank? - resource.first_name = data['first_name'] if resource.first_name.blank? - resource.last_name = data['last_name'] if resource.last_name.blank? - - if data['affiliation'] - resource.affiliations[0].organization = data['affiliation']['organization'] if resource.affiliations[0].organization.blank? - resource.affiliations[0].country = data['affiliation']['country'] if resource.affiliations[0].country.blank? - resource.affiliations[0].department = data['affiliation']['department-name'] if resource.affiliations[0].department.blank? - end +# frozen_string_literal: true - resource.omniauth_provider = data['provider'] - resource.omniauth_uid = data['uid'] +module Users + class RegistrationsController < Devise::RegistrationsController + def new + build_resource({}) + @affiliation = resource.affiliations.build + omniauth_handler if session['devise.omniauth.data'] - # delete the entry in the session - session.delete('devise.omniauth.data') + set_minimum_password_length + yield resource if block_given? + respond_with resource end - set_minimum_password_length - yield resource if block_given? - respond_with self.resource - end + def create + build_resource(sign_up_params) + find_affiliation + default_password + providers - # POST /resource - def create - build_resource(sign_up_params) - resource.affiliations = [Affiliation.find_or_create_by(resource.affiliations.first.slice(:country, :organization, :department, :group))] + yield resource if block_given? + if resource.save + resource_saved_handler + else + resource_not_saved_handler + end + end - if resource.password.nil? - resource.password = Devise.friendly_token[0,20] + protected + + def providers + provider = {} + provider[resource.provider] = resource.uid + resource.providers = provider end - resource_saved = resource.save - yield resource if block_given? - if resource_saved + def find_affiliation + resource.affiliations = [Affiliation.find_or_create_by(resource.affiliations.first.slice(:country, :organization, + :department, :group))] + end + + def default_password + resource.password = Devise.friendly_token[0, 20] if resource.password.nil? + end + + def resource_saved_handler if resource.active_for_authentication? set_flash_message :notice, :signed_up if is_flashing_format? sign_up(resource_name, resource) @@ -57,59 +53,101 @@ def create expire_data_after_sign_in! respond_with resource, location: after_inactive_sign_up_path_for(resource) end - else + end + + def resource_not_saved_handler clean_up_passwords resource @validatable = devise_mapping.validatable? - if @validatable - @minimum_password_length = resource_class.password_length.min - end + @minimum_password_length = resource_class.password_length.min if @validatable respond_with resource end - end - # GET /resource/edit - # def edit - # super - # end - - # PUT /resource - # def update - # super - # end - - # DELETE /resource - # def destroy - # super - # end - - # GET /resource/cancel - # Forces the session data which is usually expired after sign - # in to be expired now. This is useful if the user wants to - # cancel oauth signing in/up in the middle of the process, - # removing all OAuth session data. - # def cancel - # super - # end - - # protected - - # If you have extra params to permit, append them to the sanitizer. - # def configure_sign_up_params - # devise_parameter_sanitizer.permit(:sign_up, keys: [:attribute]) - # end - - # If you have extra params to permit, append them to the sanitizer. - # def configure_account_update_params - # devise_parameter_sanitizer.permit(:account_update, keys: [:attribute]) - # end - - # The path used after sign up. - # def after_sign_up_path_for(resource) - # super(resource) - # end - - # The path used after sign up for inactive accounts. - # def after_inactive_sign_up_path_for(resource) - # super(resource) - # end + def assign_email + resource.email = (resource.email.presence || session['devise.omniauth.data']['email']) + end + + def assign_name + data = session['devise.omniauth.data'] || {} + resource.first_name = (resource.first_name.presence || data['first_name']) + resource.last_name = (resource.last_name.presence || data['last_name']) + resource.name_abbreviation = (resource.name_abbreviation.presence || data['name_abbreviation']) + end + + def omniauth_handler + assign_email + assign_name + affiliation_handler + provider_handler + session.delete('devise.omniauth.data') + end + + def provider_handler + return if session['devise.omniauth.data']['provider'].blank? + + resource.provider = session['devise.omniauth.data']['provider'] + resource.uid = session['devise.omniauth.data']['uid'] + end + + def assign_affiliation(aff) + return if aff.blank? + + aff.organization = (aff.organization.presence || data['affiliation']['organization']) + aff.country = (aff.country.presence || data['affiliation']['country']) + aff.department = (aff.department.presence || data['affiliation']['department-name']) + aff + end + + def affiliation_handler + return if session['devise.omniauth.data']['affiliation'].blank? + + aff = assign_affiliation(resource.affiliations[0]) if resource.affiliations&.length.positive? # rubocop: disable Lint/SafeNavigationChain + resource.affiliations[0] = aff if aff.present? + end + + # GET /resource/edit + # def edit + # super + # end + + # PUT /resource + # def update + # super + # end + + # DELETE /resource + # def destroy + # super + # end + + # GET /resource/cancel + # Forces the session data which is usually expired after sign + # in to be expired now. This is useful if the user wants to + # cancel oauth signing in/up in the middle of the process, + # removing all OAuth session data. + # def cancel + # super + # end + + # protected + + # If you have extra params to permit, append them to the sanitizer. + # def configure_sign_up_params + # devise_parameter_sanitizer.permit(:sign_up, keys: [:attribute]) + # end + + # If you have extra params to permit, append them to the sanitizer. + # def configure_account_update_params + # devise_parameter_sanitizer.permit(:account_update, keys: [:attribute]) + # end + + # The path used after sign up. + # def after_sign_up_path_for(resource) + # super(resource) + # end + + # The path used after sign up for inactive accounts. + # def after_inactive_sign_up_path_for(resource) + # super(resource) + # end + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f7bc874a5..f621c3ef1 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -28,4 +28,24 @@ def markdown(text) @markdown ||= Redcarpet::Markdown.new(renderer, extensions) @markdown.render(text).html_safe end + + def encode_json(json) + cipher = OpenSSL::Cipher.new('rc4') + cipher.encrypt + key = cipher.random_key + iv = cipher.random_iv + encrypted = cipher.update(json.to_json) + cipher.final + encoded = Base64.encode64(encrypted) + [encoded, key.unpack("H*"), iv.unpack("H*")] + end + + def decode_json(json, key, iv) + data = Base64.decode64(json) + cipher = OpenSSL::Cipher.new('rc4') + cipher.decrypt + cipher.key = key.pack("H*") + cipher.iv = iv.pack("H*") + data = cipher.update(data) + cipher.final + JSON.parse(data) + end end diff --git a/app/jobs/chemotion_embargo_pubchem_job.rb b/app/jobs/chemotion_embargo_pubchem_job.rb new file mode 100644 index 000000000..adc05be8d --- /dev/null +++ b/app/jobs/chemotion_embargo_pubchem_job.rb @@ -0,0 +1,158 @@ +class ChemotionEmbargoPubchemJob < ActiveJob::Base + attr_reader :embargo_collection, :publication, :element, :publications + + def max_attempts + 1 + end + + def perform(embargo_col_id) + @embargo_collection = Collection.find(embargo_col_id) + @sync_emb_col = @embargo_collection.sync_collections_users&.first + pub_samples = Publication.where(ancestry: nil, element: @embargo_collection.samples).order(updated_at: :desc) + pub_reactions = Publication.where(ancestry: nil, element: @embargo_collection.reactions).order(updated_at: :desc) + @pub_list = pub_samples + pub_reactions + @pub_list.each do |embargo_pub| + @publication = Publication.find(embargo_pub.id) + @publications = [publication] + publication.descendants + @element = publication.element + publications.each do |pub| + pub.transition_from_start_to_metadata_uploading! + end + publications.each do |pub| + pub.transition_from_metadata_uploading_to_uploaded! + end + publications.each do |pub| + pub.transition_from_metadata_uploaded_to_doi_registering! + end + publications.each do |pub| + pub.transition_from_doi_registering_to_registered! + end + publications.reverse_each do |pub| + if pub.element_type == 'Sample' + pub.transition_from_doi_registered_to_pubchem_registering! + else + pub.transition_from_doi_registered_to_completing! + end + end + publications.each do |pub| + pub.transition_from_completing_to_completed! + end + end + + begin + pub_col = Publication.where(element_type: 'Collection', element_id: embargo_col_id)&.first + if pub_col.present? && pub_col.state == 'accepted' + pub_col.transition_from_start_to_metadata_uploading! + pub_col.transition_from_metadata_uploading_to_uploaded! + pub_col.transition_from_metadata_uploaded_to_doi_registering! + pub_col.transition_from_doi_registering_to_registered! + pub_col.transition_from_doi_registered_to_completing! + pub_col.transition_from_completing_to_completed! + end + rescue StandardError => e + Delayed::Worker.logger.error <<~TXT + --------- #{self.class.name} send collection DOI error ------------ + Error Message: #{e} + -------------------------------------------------------------------- + TXT + PublicationMailer.mail_job_error(self.class.name, @embargo_collection.id, "[publish collection DOI]" + e.to_s).deliver_now + raise e + end + + begin + send_pubchem + rescue StandardError => e + Delayed::Worker.logger.error <<~TXT + --------- #{self.class.name} send_pubchem error ------------ + Error Message: #{e} + -------------------------------------------------------------------- + TXT + PublicationMailer.mail_job_error(self.class.name, @embargo_collection.id, "[send_pubchem]" + e.to_s).deliver_now + raise e + end + + begin + remove_publish_pending + send_email + rescue StandardError => e + Delayed::Worker.logger.error <<~TXT + --------- ChemotionEmbargoPubchemJob remove_publish_pending or send_message error ------------ + Error Message: #{e} + ---------------------------------------------------------------------------------------------- + TXT + PublicationMailer.mail_job_error(self.class.name, @embargo_collection.id, "[remove_publish_pending or send_message error]" + e.to_s).deliver_now + raise e + end + + end + + def remove_publish_pending + @pub_list.each do |embargo_pub| + @publication = Publication.find(embargo_pub.id) + next if @publication.original_element.nil? + ot = @publication.original_element&.tag&.taggable_data&.delete('publish_pending') + @publication.original_element.tag.save! unless ot.nil? + if @publication.element_type == 'Reaction' + @publication.original_element&.samples&.each do |s| + t = s.tag&.taggable_data&.delete('publish_pending') + s.tag.save! unless t.nil? + end + end + end + end + + def send_pubchem + sdf = "" + pubchem_list = Publication.where(element: @embargo_collection.samples, state: Publication::STATE_PUBCHEM_REGISTERING) + pubchem_list.each do |pub| + metadata_obj = OpenStruct.new(sample: pub.element) + metadata_file = ERB.new(File.read( + File.join(Rails.root,'app', 'publish', 'pubchem_metadata.sdf.erb') + )) + mt = metadata_file.result(metadata_obj.instance_eval { binding }) + pub.element.update_publication_tag(pubchem_reg_at: DateTime.now) + pd = pub.taggable_data.merge(pubchem_reg_at: DateTime.now) + sdf += mt + end + message = "\n---Embargo MOLFILE START---" + #Publication.logger([message, sdf, "(Embargo Pubchem FTP upload #{Rails.env.production ? '' : 'NOT'} sent (mode: #{ENV['PUBLISH_MODE']})"]) + publication_logger ||= Logger.new(File.join(Rails.root, 'log', 'publication.log')) + message = [message, sdf, "Embargo Pubchem FTP upload sdf"].flatten.join("\n") + publication_logger.info( + <<~INFO + ******** MODE #{ENV['PUBLISH_MODE']}******** + Embargo Collection: #{@embargo_collection.id} - #{@embargo_collection.label} ( #{@embargo_collection.samples.map{ |s| s.id }} ) + #{message} + ******************************************************************************** + INFO + ) + + if Rails.env.production? && ENV['PUBLISH_MODE'] == 'production' + ftp = Net::FTP.new('ftp-private.ncbi.nlm.nih.gov') + ftp.passive = true + ftp.login(ENV['PUBCHEM_LOGIN'], ENV['PUBCHEM_PASSWORD']) + ftp.puttextcontent(sdf, @embargo_collection.id.to_s + '.sdf.in') + ftp.close + else + #file_path = Rails.public_path.join('pubchem_upload', "#{self.job_id}_#{@embargo_collection.id}.sdf.in") + #File.write(file_path, sdf) + end + + pubchem_list.each do |pub| + pub.update_columns(state: 'completing') + pub.transition_from_completing_to_completed! + end + end + + def send_email + if ENV['PUBLISH_MODE'] == 'production' + PublicationMailer.mail_publish_embargo_release(@embargo_collection.id).deliver_now + end + Message.create_msg_notification( + channel_subject: Channel::PUBLICATION_REVIEW, + message_from: @sync_emb_col.user_id, + data_args: { subject: "Congratulations! Embargo Collection #{@embargo_collection.label} released and published" } + ) + end +end + diff --git a/app/jobs/chemotion_repo_publishing_job.rb b/app/jobs/chemotion_repo_publishing_job.rb new file mode 100644 index 000000000..986066679 --- /dev/null +++ b/app/jobs/chemotion_repo_publishing_job.rb @@ -0,0 +1,214 @@ +class ChemotionRepoPublishingJob < ActiveJob::Base + attr_reader :publication, :element, :publications + INVALID_STATE = "INVALID STATE" + + def max_attempts + 1 + end + + def perform(id, action, current_user_id = 0) + @publication = Publication.find(id) + @publications = [publication] + publication.descendants + @publications.each { |d| d.update_columns(state: action) } + + @element = publication.element + publication.logger("publication STARTS") + + unless publication.state.present? + publication.logger(INVALID_STATE) + raise INVALID_STATE + end + send(publication.state) + end + + private + + def accepted + publications.each do |pub| + pub.transition_from_start_to_metadata_uploading! + end + transition_success?( + Publication::STATE_ACCEPTED, + Publication::STATE_DC_METADATA_UPLOADING + ) + dc_metadata_uploading + end + + def dc_metadata_uploading + publications.each do |pub| + pub.transition_from_metadata_uploading_to_uploaded! + end + transition_success?( + Publication::STATE_DC_METADATA_UPLOADING, + Publication::STATE_DC_METADATA_UPLOADED + ) + dc_metadata_uploaded + end + + def dc_metadata_uploaded + publications.each do |pub| + pub.transition_from_metadata_uploaded_to_doi_registering! + end + transition_success?( + Publication::STATE_DC_METADATA_UPLOADED, + Publication::STATE_DC_DOI_REGISTERING + ) + dc_doi_registering + end + + def dc_doi_registering + publications.each do |pub| + pub.transition_from_doi_registering_to_registered! + end + transition_success?( + Publication::STATE_DC_DOI_REGISTERING, + Publication::STATE_DC_DOI_REGISTERED + ) + dc_doi_registered + end + + def dc_doi_registered + publications.reverse_each do |pub| + if pub.element_type == 'Sample' + pub.transition_from_doi_registered_to_pubchem_registering! + else + pub.transition_from_doi_registered_to_completing! + end + end + transition_success?( + Publication::STATE_DC_DOI_REGISTERED, + [Publication::STATE_PUBCHEM_REGISTERING, Publication::STATE_COMPLETING] + ) + pubchem_registering + end + + def pubchem_registering + publications.each do |pub| + if pub.element_type == 'Sample' + pub.transition_from_doi_pubchem_registering_to_registered! + else + pub.transition_from_doi_registered_to_completing! + end + end + transition_success?( + Publication::STATE_PUBCHEM_REGISTERING, + [Publication::STATE_PUBCHEM_REGISTERED, Publication::STATE_COMPLETING] + ) + pubchem_registered + + rescue StandardError => e + Delayed::Worker.logger.error <<~TXT + --------- #{self.class.name} pubchem_registering error ------------ + Error Message: #{e} + -------------------------------------------------------------------- + TXT + PublicationMailer.mail_job_error(self.class.name, @publication.id, "[pubchem_registering]" + e.to_s).deliver_now + raise e + end + + def pubchem_registered + publications.each do |pub| + if pub.element_type == 'Sample' + pub.transition_from_pubchem_registered_to_completing! + else + pub.transition_from_doi_registered_to_completing! + end + end + transition_success?( + Publication::STATE_PUBCHEM_REGISTERED, + Publication::STATE_COMPLETING + ) + completing + end + + def completing + publications.each do |pub| + pub.transition_from_completing_to_completed! + end + transition_success?( + Publication::STATE_COMPLETING, + Publication::STATE_COMPLETED + ) + completed + end + + def completed + remove_publish_pending + replace_old_sample_version_in_reaction + + if ENV['PUBLISH_MODE'] == 'production' + PublicationMailer.mail_publish_approval(@publication.id).deliver_now + end + submitter = @publication.published_by || @publication.taggable_data['creators']&.first&.dig('id') + sgl = @publication.review.dig('reviewers').nil? ? [submitter] : @publication.review.dig('reviewers') + [submitter] + Message.create_msg_notification( + channel_subject: Channel::PUBLICATION_REVIEW, + message_from: submitter, + message_to: sgl, + data_args: {subject: "Congratulations! Chemotion Repository: #{@publication.doi.suffix} published"} + ) + rescue StandardError => e + Delayed::Worker.logger.error <<~TXT + --------- #{self.class.name} completed error ------------ + Error Message: #{e} + -------------------------------------------------------------------- + TXT + PublicationMailer.mail_job_error(self.class.name, @publication.id, "[completed]" + e.to_s).deliver_now + raise e + end + + def published + end + + def remove_publish_pending + unless @element&.tag&.taggable_data['previous_version'].nil? + @element&.tag&.taggable_data&.delete('publish_pending') + @element&.tag.save! + else + return if @publication.original_element.nil? + ot = @publication.original_element&.tag&.taggable_data&.delete('publish_pending') + @publication.original_element&.tag.save! unless ot.nil? + if @publication.element_type == 'Reaction' + @publication.original_element&.samples&.each do |s| + t = s.tag&.taggable_data&.delete('publish_pending') + s.tag.save! unless t.nil? + end + end + end + end + + def replace_old_sample_version_in_reaction + # check if this is a new version of a sample in a public reaction + # if so, replace the previous version in this reaction + unless @element&.tag&.taggable_data['previous_version'].nil? + return unless @publication.element_type == 'Sample' + + if @element&.tag&.taggable_data['replace_in_publication'] + previous_version_id = @element&.tag&.taggable_data['previous_version']['id'] + reaction_id = @element&.tag&.taggable_data['reaction_id'] + + unless reaction_id.nil? + reaction = Collection.public_collection.reactions.find_by(id: reaction_id) + unless reaction.nil? + reaction_sample = reaction.reactions_samples.find_by(sample_id: previous_version_id) + reaction_sample.sample_id = @element.id + reaction_sample.save! + + @element.untag_replace_in_publication + end + end + end + end + end + + def transition_success?(from_state, to_state) + failed = publications.select{|pub| !([to_state].flatten.include?(pub.state))} + .map{|pub| [pub.id, pub.state]} + if failed.present? + message = "TRANSITION FROM #{from_state} to #{to_state}, FAILED\n failed for publications: #{failed}" + publication.logger(message) + raise message + end + true + end +end diff --git a/app/jobs/chemotion_repo_reviewing_job.rb b/app/jobs/chemotion_repo_reviewing_job.rb new file mode 100644 index 000000000..a7e346e60 --- /dev/null +++ b/app/jobs/chemotion_repo_reviewing_job.rb @@ -0,0 +1,99 @@ +class ChemotionRepoReviewingJob < ActiveJob::Base + attr_reader :publication, :publications + INVALID_STATE = "INVALID STATE" + + def perform(id, action, current_user_id = 0) + @publication = Publication.find(id) + @publications = [publication] + publication.descendants + @publications.each { |d| d.update_columns(state: action) } + @current_user_id = current_user_id + # @element = publication.element + publication.logger("publication STARTS") + + unless publication.state.present? + publication.logger(INVALID_STATE) + raise INVALID_STATE + end + + # process_element + notify_users + mail_users + remove_new_version_if_declined + end + + private + def notify_users + submitter = publication.published_by || publication.taggable_data['creators']&.first&.dig('id') + + args = { + channel_subject: Channel::PUBLICATION_REVIEW, + message_from: submitter + } + sgl = publication.review.dig('reviewers').nil? ? [submitter] : publication.review.dig('reviewers') + [submitter] + + case publication.state + when Publication::STATE_PENDING + args[:message_to] = User.reviewer_ids + (publication.review.dig('reviewers') || []) + args[:data_args] = { + subject: "Chemotion Repository: Review Request for #{publication.doi.suffix}" + } + when Publication::STATE_REVIEWED + args[:data_args] = { + subject: "Chemotion Repository: Modification request for #{publication.doi.suffix} \n Please check the reviewer's comments and request for modification. \n Once done with the revision, you can then resubmit your publication." + } + when Publication::STATE_ACCEPTED + args[:data_args] = { + subject: "Chemotion Repository: Publication Accepted. for #{publication.doi.suffix} \n Once the embargo is released, this submission will be published." + } + when Publication::STATE_DECLINED + args[:message_to] = sgl + title = publication.published_by == @current_user_id ? 'withdrawn' : 'rejected' + args[:data_args] = { + subject: "Chemotion Repository: Publication #{title}. for #{publication.doi.suffix}." + } + # when Publication::STATE_RETRACTED + else + return + end + Message.create_msg_notification(args) + end + + def mail_users + return unless ENV['PUBLISH_MODE'] == 'production' + + case publication.state + when Publication::STATE_PENDING + PublicationMailer.mail_reviewing_request(publication.id).deliver_now + when Publication::STATE_REVIEWED + PublicationMailer.mail_reviewed_request(publication.id).deliver_now + when Publication::STATE_ACCEPTED + PublicationMailer.mail_accepted_request(publication.id).deliver_now + when Publication::STATE_DECLINED + PublicationMailer.mail_declined_request(publication.id, @current_user_id).deliver_now unless @current_user_id.zero? + else + return + end + rescue StandardError => e + Delayed::Worker.logger.error <<~TXT + --------- #{self.class.name} mail_users error ------------ + Error Message: #{e} + -------------------------------------------------------------------- + TXT + + end + + def remove_new_version_if_declined + case publication.state + when Publication::STATE_DECLINED + unless publication.element&.tag&.taggable_data['previous_version'].nil? + @publications.each do |publication| + publication.element.destroy + publication.doi.destroy + end + end + else + return + end + end + +end diff --git a/app/jobs/pubchem_sid_job.rb b/app/jobs/pubchem_sid_job.rb new file mode 100644 index 000000000..8ffb7b31a --- /dev/null +++ b/app/jobs/pubchem_sid_job.rb @@ -0,0 +1,16 @@ +# Job to update publication SID by using DOI +class PubchemSidJob < ActiveJob::Base + queue_as :pubchem_sid + + # NB: PC has request restriction policy and timeout , hence the sleep_time params + # see http://pubchemdocs.ncbi.nlm.nih.gov/programmatic-access$_RequestVolumeLimitations + def perform(sleep_time: 10) + Publication.where(element_type: 'Sample') + .where("taggable_data->>'sid' isnull and taggable_data->>'doi' IS NOT NULL and state = 'completed'") + .order("id asc").each do |pub| + sid_info = Chemotion::PubchemService.sid_from_doi(pub.taggable_data["doi"]) + pub.update!(taggable_data: pub.taggable_data.merge(sid: sid_info)) unless sid_info.nil? + sleep sleep_time + end + end + end diff --git a/app/jobs/send_welcome_email_job.rb b/app/jobs/send_welcome_email_job.rb index 64e097025..19d8b0dfc 100644 --- a/app/jobs/send_welcome_email_job.rb +++ b/app/jobs/send_welcome_email_job.rb @@ -1,8 +1,10 @@ class SendWelcomeEmailJob < ApplicationJob - + queue_as :send_welcome_email - + def perform(user_id) WelcomeMailer.mail_welcome_message(user_id).deliver_now + rescue StandardError => e + Rails.logger.error e end end \ No newline at end of file diff --git a/app/jobs/transfer_file_from_tmp_job.rb b/app/jobs/transfer_file_from_tmp_job.rb index 61f67ec67..caa653b49 100644 --- a/app/jobs/transfer_file_from_tmp_job.rb +++ b/app/jobs/transfer_file_from_tmp_job.rb @@ -5,7 +5,9 @@ def perform(attach_ary) primary_store = Rails.configuration.storage.primary_store begin attach_ary.each do |attach_id| - attachment = Attachment.find(attach_id) + attachment = Attachment.find_by(id: attach_id) + next unless attachment + if attachment.storage == 'tmp' attachment.update!(storage: primary_store) end diff --git a/app/jobs/transfer_thumbnail_to_public_job.rb b/app/jobs/transfer_thumbnail_to_public_job.rb index b25b2dfff..c6f4c7e9c 100644 --- a/app/jobs/transfer_thumbnail_to_public_job.rb +++ b/app/jobs/transfer_thumbnail_to_public_job.rb @@ -5,7 +5,9 @@ class TransferThumbnailToPublicJob < ApplicationJob queue_as :transfer_thumbnail_to_public def perform(attach_ary) attach_ary.each do |attach_id| - a = Attachment.find(attach_id) + a = Attachment.find_by(id: attach_id) + next unless a + file_path = Rails.public_path.join('images', 'thumbnail', a.identifier) File.write(file_path, a.read_thumbnail.force_encoding('UTF-8')) if a.read_thumbnail end diff --git a/app/mailers/publication_mailer.rb b/app/mailers/publication_mailer.rb new file mode 100644 index 000000000..d0d9ac39c --- /dev/null +++ b/app/mailers/publication_mailer.rb @@ -0,0 +1,419 @@ +class PublicationMailer < ActionMailer::Base + default from: ENV['DEVISE_SENDER'] || 'eln' + + def external_review_content + <<~TXT + #{ENV['PUBLISH_MODE'] == 'staging' ? "TESTING MODE" : ""} + + A temporary account for #{@embargo_collection} has been created! + Account: #{@external_acct} + Password: #{@external_pwd} + + You can use this account/password to access: + #{@proto + @host}/home/embargo + TXT + end + + def reviewing_content + <<~TXT + #{ENV['PUBLISH_MODE'] == 'staging' ? "TESTING MODE" : ""} + Creator: #{@creator&.name} + #{@publication.element_type} ##{@publication.element_id} is pending for publication. + Please help reviewing it at: + #{@proto + @host}/home/review/review_#{@publication.element_type.underscore}/#{@publication.element_id} + TXT + end + + def reviewed_content + <<~TXT + #{ENV['PUBLISH_MODE'] == 'staging' ? "TESTING MODE" : ""} + Dear #{@creator&.name} + + #{@publication.element_type} ##{@publication.element_id} has been reviewed. + + Please check the reviewer's comments and request for modification: + #{@proto + @host}/home/review/review_#{@publication.element_type.underscore}/#{@publication.element_id} + + You can also see the comments and edit your publication directly from your [Reviewing] collection at: + #{@proto + @host}/mydb/scollection/#{@creator_reviewing_scol_id}/#{@publication.element_type.underscore}/#{@publication.element_id} + + Once done with the revision, you can then resubmit your publication. + TXT + end + + def accepted_content + <<~TXT + #{ENV['PUBLISH_MODE'] == 'staging' ? "TESTING MODE" : ""} + Dear #{@creator&.name} + + #{@publication.element_type} ##{@publication.element_id} has been accepted. + + Once the embargo is released, this publication will be published. + TXT + end + + def approved_content + <<~TXT + #{ENV['PUBLISH_MODE'] == 'staging' ? "TESTING MODE" : ""} + Congratulations! + Your #{@publication.element_type} ##{@publication.element_id} has been published. + + See it at: + #{@proto + @host}/inchikey/#{@doi.suffix} + TXT + end + + + def embargo_released_content + <<~TXT + #{ENV['PUBLISH_MODE'] == 'staging' ? "TESTING MODE" : ""} + Congratulations! + Your embargo #{@embargo_collection} has been released. + + See it at: + #{@proto + @host}/mydb/scollection/#{@sync_emb_col.id} + TXT + end + + def declined_content + <<~TXT + #{ENV['PUBLISH_MODE'] == 'staging' ? "TESTING MODE" : ""} + Congratulations! + Your #{@publication.element_type} ##{@publication.element_id} has been declined. + + See it at: + #{@proto + @host}/mydb/collection/all/#{@publication.element_type.underscore}/#{@publication.element_id.to_s} + TXT + end + + def subject(prefix) + "Chemotion Repository: #{prefix} for #{@doi.suffix}" + end + + def reviewers + # Person.where(id: User.reviewer_ids).pluck :email + [] + end + + def group_leaders + reviewers = @publication&.review&.dig('reviewers') + if reviewers.nil? + [] + else + Person.where(id: reviewers).pluck(:email) + end + end + + def init_params(publication_id,current_user_id=0) + @publication = Publication.find(publication_id) + @doi = @publication.doi + # @publications = [@publication] + @publication.descendants + # @element = @publication.element + @is_submitter = @publication.published_by == current_user_id || false + @creator_id = @publication.published_by || @publication.taggable_data['creators']&.first&.dig('id') + @creator = User.find(@creator_id) unless @creator_id.nil? + @creator_reviewing_scol_id = @creator.reviewing_collection&.sync_collections_users&.first&.id if @publication.state === 'reviewed' + + case ENV['PUBLISH_MODE'] + when 'production' + if Rails.env.production? + @proto = "https://" + @host = "www.chemotion-repository.net" + end + when 'staging' + @proto = "http://" + @host = ENV['HOST'] || "localhost:3000" + end + end + + def init_external_params(current_user, collection_label, email, pwd) + @creator = current_user + @embargo_collection = collection_label + @external_acct = email + @external_pwd = pwd + + case ENV['PUBLISH_MODE'] + when 'production' + if Rails.env.production? + @proto = "https://" + @host = "www.chemotion-repository.net" + end + when 'staging' + @proto = "http://" + @host = ENV['HOST'] || "localhost:3000" + end + end + + def mail_reviewed_request(publication_id) + init_params(publication_id) + #reviewed_notify + mail( + to: @creator.email, + bcc: reviewers, + subject: subject('Publication Reviewed'), + ) do |format| + format.html + format.text { render plain: reviewed_content } + end + end + + def mail_reviewing_request(publication_id) + init_params(publication_id) + #reviewing_notify + # return unless reviewers.present? + return if @publication.review.dig('reviewers').nil? || @publication.review.dig('history').length > 2 + + mail( + to: group_leaders, + subject: subject('Pending for publication. Review Request') + ) do |format| + format.html + format.text { render plain: reviewing_content } + end + end + + def mail_accepted_request(publication_id) + init_params(publication_id) + #accepted_notify + mail( + to: @creator.email, + bcc: reviewers, + subject: subject('Publication Accepted') + ) do |format| + format.html + format.text { render plain: accepted_content } + end + end + + def mail_publish_approval(publication_id) + init_params(publication_id) + #approved_notify + mail( + to: @creator.email, + bcc: reviewers, + subject: subject('Publication Approved'), + ) do |format| + format.html + format.text { render plain: approved_content } + end + end + + def mail_publish_embargo_release(embargo_col_id) + @embargo_collection = Collection.find(embargo_col_id) + @sync_emb_col = @embargo_collection.sync_collections_users&.first + @creator = User.find(@sync_emb_col.user_id) + + case ENV['PUBLISH_MODE'] + when 'production' + if Rails.env.production? + @proto = "https://" + @host = "www.chemotion-repository.net" + end + when 'staging' + @proto = "http://" + @host = ENV['HOST'] || "localhost:3000" + end + + #approved_notify + mail( + to: @creator.email, + bcc: reviewers, + subject: "Chemotion Repository: Embargo collection: #{@embargo_collection.label} released", + ) do |format| + format.html + format.text { render plain: embargo_released_content } + end + end + + def mail_declined_request(publication_id, current_user_id) + init_params(publication_id,current_user_id) + @action_title = @is_submitter ? 'withdrawn' : 'rejected' + + #rejected_notify + mail( + to: @creator.email, + bcc: reviewers, + subject: subject('Publication '+@action_title), + ) do |format| + format.html + format.text { render plain: approved_content } + end + end + + def mail_job_error(job_name, id, msg) + @job_name = job_name + @id = id.to_s + @msg = msg + it_email = ENV['HELPDESK'].presence.split(/,/)&.map(&:strip) + return unless it_email.present? + mail( + to: it_email, + subject: "Chemotion Repository Job Error, Job: [" + job_name + "], Id: ["+ @id +"]", + ) do |format| + format.html + format.text { render plain: "mail_job_error" } + end + end + + def mail_external_review(current_user, collection_label, email, pwd) + init_external_params(current_user, collection_label, email, pwd) + #anonymous_notify + mail( + to: @creator.email, + bcc: reviewers, + subject: "Chemotion Repository: A temporary account for ["+ @embargo_collection +"] has been created", + ) do |format| + format.html + format.text { render plain: external_review_content } + end + end + + def mail_user_comment(current_user, id, type, pageId, pageType, comment) + @current_user = current_user + @pageType = pageType + @pageId = pageId + first_reviewer_email = Person.where(id: User.reviewer_ids).first.email + + @user_comment = comment.gsub("\n", "\r\n") + @publication = Publication.find_by(element_type: type, element_id: id) + + @element_type = (type == 'Container') ? 'Analysis' : type + @element_type = 'Product' if pageType == 'reactions' && type == 'Sample' + + case type + when 'Container' + @pub_id = "CRD-#{@publication.id}" + @doi = @publication && @publication.taggable_data && @publication.taggable_data["analysis_doi"] + when 'Sample' + @pub_id = "CRS-#{@publication.id}" + @doi = @publication && @publication.taggable_data && @publication.taggable_data["doi"] + when 'Reaction' + @pub_id = "CRR-#{@publication.id}" + @doi = @publication && @publication.taggable_data && @publication.taggable_data["doi"] + end + + case ENV['PUBLISH_MODE'] + when 'production' + if Rails.env.production? + @proto = "https://" + @host = "www.chemotion-repository.net" + end + when 'staging' + @proto = "http://" + @host = ENV['HOST'] || "localhost:3000" + end + + mail( + to: ENV['DEVISE_SENDER'], + cc: current_user.email, + bcc: first_reviewer_email, + subject: "Comments from Chemotion User [" + current_user.name + "]", + ) do |format| + format.html + format.text { render plain: "User comments" } + end + end + + def compound_params(current_user, id, data) + @current_user = current_user + @mail_content = data.gsub("\n", "\r\n") + @publication = Publication.find_by(element_type: 'Sample', element_id: id) + @sample = @publication.element + @pub_id = "CRS-#{@publication.id}" + @doi = @publication&.taggable_data && @publication.taggable_data['doi'] + @suffix = @publication&.doi&.suffix + @xvial = (@sample.tag.taggable_data && @sample.tag.taggable_data['xvial'] && @sample.tag.taggable_data['xvial']['num']) || '' + case ENV['PUBLISH_MODE'] + when 'production' + if Rails.env.production? + @protocol = 'https://' + @host = 'www.chemotion-repository.net' + end + when 'staging' + @protocol = 'http://' + @host = ENV['HOST'] || 'localhost:3000' + end + end + + def compound_request_content_plain + <<~TXT + #{ENV['PUBLISH_MODE'] == 'staging' ? 'TESTING MODE' : ''} + Here is a request for Compound X-Vial: #{@xvial} from Chemotion User [#{@current_user.name}]: + + Chemotion Id: #{@pub_id} + DOI: #{@doi} + Compound X-Vial: #{@xvial} + Request by: #{@current_user.name} [#{@current_user.email}] + + See it at: + #{@protocol + @host}/inchikey/#{@suffix} + TXT + end + + def compound_request_content + @subject = '[Request] A request from Chemotion Repository User [' + @current_user.name + ']' + @mail_base = "Dear Compound team + + Here is a request for Compound X-Vial: #{@xvial} from Chemotion User [#{@current_user.name}]: + + Chemotion Id: #{@pub_id} + DOI: #{@doi} + Compound X-Vial: #{@xvial} + Request by: #{@current_user.name} [#{@current_user.email}]" + @mail_base = @mail_base.gsub("\n", "\r\n") + end + + def compound_confirmation_content_plain + <<~TXT + #{ENV['PUBLISH_MODE'] == 'staging' ? 'TESTING MODE' : ''} + Your request for Chemotion Id: #{@pub_id} has been delivered to Compound platform: + + Chemotion Id: #{@pub_id} + DOI: #{@doi} + Request by: #{@current_user.name} [#{@current_user.email}] + + See it at: + #{@protocol + @host}/inchikey/#{@suffix} + TXT + end + + def compound_confirmation_content + @subject = '[Confirmation] Your request has been delivered to Compound platform' + @mail_base = "Dear #{@current_user.name}, + + Your request for Chemotion Id: #{@pub_id} has been delivered to Compound platform: + + Chemotion Id: #{@pub_id} + DOI: #{@doi} + Request by: #{@current_user.name} [#{@current_user.email}]" + @mail_base = @mail_base.gsub("\n", "\r\n") + end + + def compound_request + compound_request_content + mail( + to: ENV['COMPOUND_TEAM'].presence.split(/,/)&.map(&:strip), + subject: @subject + ) do |format| + format.html + format.text { render plain: compound_request_content_plain } + end + end + + def compound_confirmation + compound_confirmation_content + mail( + to: @current_user.email, + subject: @subject + ) do |format| + format.html + format.text { render plain: compound_confirmation_content_plain } + end + end + + def mail_request_compound(current_user, id, data, mail_type) + compound_params(current_user, id, data) + compound_request if mail_type == 'request' + compound_confirmation if mail_type == 'confirmation' + end +end diff --git a/app/mailers/welcome_mailer.rb b/app/mailers/welcome_mailer.rb index faf75c485..cdbe6ab2d 100644 --- a/app/mailers/welcome_mailer.rb +++ b/app/mailers/welcome_mailer.rb @@ -11,9 +11,8 @@ def markdown(text) def mail_welcome_message(user_id) @user = User.find(user_id) @message = File.read("#{Rails.root}/public/welcome-message.md") - @output = markdown(@message); - - mail(to: @user.email, subject: "[ELN] Welcome to Chemotion.") + @output = markdown(@message) + + mail(to: @user.email, subject: "Welcome to Chemotion Repository.") end - -end \ No newline at end of file +end diff --git a/app/models/admin.rb b/app/models/admin.rb index 84e417447..53d0a2ea8 100644 --- a/app/models/admin.rb +++ b/app/models/admin.rb @@ -25,19 +25,18 @@ # name_abbreviation :string(12) # type :string default("Person") # reaction_name_prefix :string(3) default("R") +# layout :hstore not null # confirmation_token :string # confirmed_at :datetime # confirmation_sent_at :datetime # unconfirmed_email :string -# layout :hstore not null # selected_device_id :integer # failed_attempts :integer default(0), not null # unlock_token :string # locked_at :datetime # account_active :boolean # matrix :integer default(0) -# omniauth_provider :string -# omniauth_uid :string +# providers :jsonb # # Indexes # diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index d6b87eaf7..eb7d16cb3 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -28,13 +28,18 @@ class Affiliation < ApplicationRecord has_many :users, through: :user_affiliations def output_array_full - [group, department, organization, country] + [department, organization, country] end def output_full output_array_full.map{|e| !e.blank? && e || nil}.compact.join(', ') end + # def output_dep_org_country + # output_array_full[1..-1].map{|e| !e.blank? && e || nil}.compact.join(', ') + # end + + private def from_to diff --git a/app/models/anonymous.rb b/app/models/anonymous.rb new file mode 100644 index 000000000..ccf775591 --- /dev/null +++ b/app/models/anonymous.rb @@ -0,0 +1,53 @@ +# == Schema Information +# +# Table name: users +# +# id :integer not null, primary key +# email :string default(""), not null +# encrypted_password :string default(""), not null +# reset_password_token :string +# reset_password_sent_at :datetime +# remember_created_at :datetime +# sign_in_count :integer default(0), not null +# current_sign_in_at :datetime +# last_sign_in_at :datetime +# current_sign_in_ip :inet +# last_sign_in_ip :inet +# created_at :datetime not null +# updated_at :datetime not null +# name :string +# first_name :string not null +# last_name :string not null +# deleted_at :datetime +# counters :hstore not null +# name_abbreviation :string(12) +# type :string default("Person") +# reaction_name_prefix :string(3) default("R") +# layout :hstore not null +# confirmation_token :string +# confirmed_at :datetime +# confirmation_sent_at :datetime +# unconfirmed_email :string +# selected_device_id :integer +# failed_attempts :integer default(0), not null +# unlock_token :string +# locked_at :datetime +# account_active :boolean +# matrix :integer default(0) +# providers :jsonb +# +# Indexes +# +# index_users_on_confirmation_token (confirmation_token) UNIQUE +# index_users_on_deleted_at (deleted_at) +# index_users_on_email (email) UNIQUE +# index_users_on_name_abbreviation (name_abbreviation) UNIQUE WHERE (name_abbreviation IS NOT NULL) +# index_users_on_reset_password_token (reset_password_token) UNIQUE +# index_users_on_unlock_token (unlock_token) UNIQUE +# + +class Anonymous < User + def send_on_create_confirmation_instructions + + end +end diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 615166a7c..6c7df6cfe 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -230,6 +230,12 @@ def store_file_and_thumbnail_for_dup else self.thumb_data = store.read_thumb end + #TODO check for path collision: + # depending on storage, key needs to be changed + if self.storage == 'tmp' + self.key = nil + generate_key + end stored = store.store_file self.thumb = store.store_thumb if stored self.save if stored diff --git a/app/models/channel.rb b/app/models/channel.rb index 72678b67c..46537b954 100644 --- a/app/models/channel.rb +++ b/app/models/channel.rb @@ -27,12 +27,16 @@ class Channel < ApplicationRecord SEND_IMPORT_NOTIFICATION = 'Import Notification' COMPUTED_PROPS_NOTIFICATION = 'Computed Prop Notification' GATE_TRANSFER_NOTIFICATION = 'Gate Transfer Completed' + CHEMSCANNER_NOTIFICATION = 'ChemScanner Notification' COLLECTION_TAKE_OWNERSHIP = 'Collection Take Ownership' EDITOR_CALLBACK = 'EditorCallback' COLLECTION_ZIP = 'Collection Import and Export' COLLECTION_ZIP_FAIL = 'Collection Import and Export Failure' CHEM_SPECTRA_NOTIFICATION = 'Chem Spectra Notification' ASSIGN_INBOX_TO_SAMPLE = 'Assign Inbox Attachment to Sample' + # REPOSITORY ONLY + PUBLICATION_REVIEW = 'Publication Review' + PUBLICATION_PUBLISHED = 'Publication Published' class << self def build_message(**args) diff --git a/app/models/chemscanner.rb b/app/models/chemscanner.rb new file mode 100644 index 000000000..0dbea6756 --- /dev/null +++ b/app/models/chemscanner.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Chemscanner models +module Chemscanner + def self.table_name_prefix + 'chemscanner_' + end +end diff --git a/app/models/chemscanner/molecule.rb b/app/models/chemscanner/molecule.rb new file mode 100644 index 000000000..8086b7805 --- /dev/null +++ b/app/models/chemscanner/molecule.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: chemscanner_molecules +# +# id :integer not null, primary key +# scheme_id :integer not null +# external_id :integer +# clone_from :integer +# mdl :string +# cano_smiles :string +# label :string +# abbreviation :string +# description :string +# aliases :jsonb +# details :jsonb +# extended_metadata :jsonb +# is_approved :boolean default(FALSE) +# imported_id :integer +# created_at :datetime not null +# updated_at :datetime not null +# deleted_at :datetime +# inchistring :string +# inchikey :string +# + +module Chemscanner + # ChemScanner scanned molecule(s) + class Molecule < ApplicationRecord + acts_as_paranoid + + belongs_to :scheme, class_name: 'Scheme', foreign_key: :scheme_id + + has_many :reactions_molecules, dependent: :destroy + has_many :reactions_reactant_molecules, dependent: :destroy + has_many :reactions_reagent_molecules, dependent: :destroy + has_many :reactions_product_molecules, dependent: :destroy + + has_many :reactions, through: :reactions_molecules + has_many :reactions_as_reactant, through: :reactions_reactant_molecules, source: :reaction + has_many :reactions_as_reagent, through: :reactions_reagnet_molecules, source: :reaction + has_many :reactions_as_product, through: :reactions_product_molecules, source: :reaction + + # rubocop:disable AbcSize + def assign_from_chemscanner(molecule) + aliases = molecule.atom_map.each_with_object({}) do |(_, atom), hash| + next hash unless atom.is_alias && !atom.alias_text.empty? + + hash[atom.get_idx] = atom.alias_text + end + + inchi, inchikey = Chemotion::OpenBabelService.inchi_info_from_molfile(molecule.mdl) + + assign_attributes( + external_id: molecule.id, + mdl: molecule.mdl, + cano_smiles: molecule.cano_smiles, + clone_from: molecule.clone_from, + abbreviation: molecule.abbreviation, + inchistring: inchi, + inchikey: inchikey, + label: molecule.label, + description: molecule.text, + details: molecule.details.to_hash.to_json, + aliases: aliases.to_json + ) + end + # rubocop:enable AbcSize + + def set_polymer(atom_idx) + mdl_lines = mdl.split("\n") + end_line_idx = mdl_lines.find_index { |l| l == 'M END' } + return false if end_line_idx.nil? + + ext_data = extended_metadata + + polymers = ext_data['polymer'] || [] + if polymers.include?(atom_idx) + polymers.delete_if { |idx| idx == atom_idx } + else + polymers.push(atom_idx) + end + + has_polymer = !polymers.empty? + ext_data['polymer'] = polymers + + rgp_line_idx = mdl_lines.find_index { |l| l.match(/^M RGP/) } + mdl_lines.delete_at(rgp_line_idx) unless rgp_line_idx.nil? + if has_polymer + rgp_info = polymers.map { |idx| "#{idx + 1} 1" }.join(' ') + rgp_line = "M RGP #{polymers.count} #{rgp_info}" + + mdl_lines.insert(end_line_idx, rgp_line) + end + + polymer_line_idx = mdl_lines.find_index { |l| l == '> ' } + unless polymer_line_idx.nil? + mdl_lines.delete_at(polymer_line_idx + 1) + mdl_lines.delete_at(polymer_line_idx) + end + + if has_polymer + mdl_lines.insert(end_line_idx + 2, polymers.join(' ')) + mdl_lines.insert(end_line_idx + 2, '> ') + end + mdl_lines.push('$$$$') if mdl_lines.last.strip != '$$$$' + + update(extended_metadata: ext_data, mdl: mdl_lines.join("\n")) + end + end +end diff --git a/app/models/chemscanner/reaction.rb b/app/models/chemscanner/reaction.rb new file mode 100644 index 000000000..98d35b00f --- /dev/null +++ b/app/models/chemscanner/reaction.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: chemscanner_reactions +# +# id :integer not null, primary key +# scheme_id :integer not null +# external_id :integer not null +# clone_from :integer +# description :string +# temperature :string +# time :string +# status :string +# yield :float +# details :jsonb +# extended_metadata :jsonb +# is_approved :boolean default(FALSE) +# imported_id :integer +# created_at :datetime not null +# updated_at :datetime not null +# deleted_at :datetime +# + + +module Chemscanner + # ChemScanner scanned reaction(s) + class Reaction < ApplicationRecord + acts_as_paranoid + + belongs_to :scheme, class_name: 'Scheme', foreign_key: :scheme_id + + has_many :steps, class_name: 'ReactionStep', foreign_key: :reaction_id + + has_many :reactions_molecules, dependent: :destroy + has_many :molecules, through: :reactions_molecules, source: :molecule + + has_many :reactions_reactant_molecules, + class_name: 'ReactionsReactantMolecule', + dependent: :destroy + has_many :reactants, + through: :reactions_reactant_molecules, source: :molecule + + has_many :reactions_reagent_molecules, + class_name: 'ReactionsReagentMolecule', + dependent: :destroy + has_many :reagents, + through: :reactions_reagent_molecules, source: :molecule + + has_many :reactions_solvent_molecules, + class_name: 'ReactionsSolventMolecule', + dependent: :destroy + has_many :solvents, + through: :reactions_solvent_molecules, source: :molecule + + has_many :reactions_product_molecules, + class_name: 'ReactionsProductMolecule', + dependent: :destroy + has_many :products, + through: :reactions_product_molecules, + source: :molecule + + def assign_from_chemscanner(reaction) + assign_attributes( + external_id: reaction.arrow_id, + clone_from: reaction.clone_from, + description: reaction.description, + temperature: reaction.temperature, + time: reaction.time, + status: reaction.status, + yield: reaction.yield, + details: reaction.details.to_h.to_json + ) + end + + def remove_reagent_smiles(smiles) + to_remove = reagents.select { |m| smiles.include?(m.cano_smiles) } + ids = to_remove.map(&:id) + to_remove.each(&:destroy) + + save! + ids + end + + def add_reagent_smiles(smiles) + max_id = (reactants + reagents + products).map(&:external_id).max + + added = [] + smiles.each do |s| + rw_mol = RDKitChem::RWMol.mol_from_smiles(s) + + m = Molecule.create( + cano_smiles: s, + scheme_id: scheme_id, + mdl: rw_mol.mol_to_mol_block(true, -1, false), + external_id: max_id += 1 + ) + + rm = ReactionsMolecule.create( + type: 'Chemscanner::ReactionsReagentMolecule', + reaction_id: id, + molecule_id: m.id + ) + reactions_reagent_molecules << rm + added.push(m) + end + + save! + + added + end + end +end diff --git a/app/models/chemscanner/reaction_step.rb b/app/models/chemscanner/reaction_step.rb new file mode 100644 index 000000000..91c0e7f75 --- /dev/null +++ b/app/models/chemscanner/reaction_step.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: chemscanner_reaction_steps +# +# id :integer not null, primary key +# reaction_id :integer not null +# reaction_external_id :integer not null +# reagent_ids :integer default([]), is an Array +# reagent_smiles :string default([]), is an Array +# step_number :integer not null +# description :string +# temperature :string +# time :string +# created_at :datetime not null +# updated_at :datetime not null +# deleted_at :datetime +# + + +# ChemScanner scanned reaction(s) +module Chemscanner + # ChemScanner reaction steps + class ReactionStep < ApplicationRecord + acts_as_paranoid + + belongs_to :reaction, class_name: 'Reaction', foreign_key: :output_id + + def assign_from_chemscanner(step) + assign_attributes( + description: step.description, + temperature: step.temperature, + time: step.time + ) + end + end +end diff --git a/app/models/chemscanner/reactions_molecule.rb b/app/models/chemscanner/reactions_molecule.rb new file mode 100644 index 000000000..37e804a8b --- /dev/null +++ b/app/models/chemscanner/reactions_molecule.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: chemscanner_reactions_molecules +# +# id :integer not null, primary key +# reaction_id :integer not null +# molecule_id :integer not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# deleted_at :datetime +# + + +module Chemscanner + # Chemscanner reaction molecule join table + class ReactionsMolecule < ApplicationRecord + acts_as_paranoid + + belongs_to :reaction + belongs_to :molecule + end + + class ReactionsReactantMolecule < ReactionsMolecule + end + + class ReactionsReagentMolecule < ReactionsMolecule + end + + class ReactionsSolventMolecule < ReactionsMolecule + end + + class ReactionsProductMolecule < ReactionsMolecule + end +end diff --git a/app/models/chemscanner/reactions_product_molecule.rb b/app/models/chemscanner/reactions_product_molecule.rb new file mode 100644 index 000000000..4f8277bbf --- /dev/null +++ b/app/models/chemscanner/reactions_product_molecule.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: chemscanner_reactions_molecules +# +# id :integer not null, primary key +# reaction_id :integer not null +# molecule_id :integer not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# deleted_at :datetime +# + + +module Chemscanner + class ReactionsProductMolecule < ReactionsMolecule + # STI: this file is only here because of rails model autoloading. + # place all code in app/models/chemscanner/reactions_molecule.rb. + end +end diff --git a/app/models/chemscanner/reactions_reactant_molecule.rb b/app/models/chemscanner/reactions_reactant_molecule.rb new file mode 100644 index 000000000..1b14aaf1c --- /dev/null +++ b/app/models/chemscanner/reactions_reactant_molecule.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: chemscanner_reactions_molecules +# +# id :integer not null, primary key +# reaction_id :integer not null +# molecule_id :integer not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# deleted_at :datetime +# + + +module Chemscanner + class ReactionsReactantMolecule < ReactionsMolecule + # STI: this file is only here because of rails model autoloading. + # place all code in app/models/chemscanner/reactions_molecule.rb. + end +end diff --git a/app/models/chemscanner/reactions_reagent_molecule.rb b/app/models/chemscanner/reactions_reagent_molecule.rb new file mode 100644 index 000000000..a27ade2fa --- /dev/null +++ b/app/models/chemscanner/reactions_reagent_molecule.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: chemscanner_reactions_molecules +# +# id :integer not null, primary key +# reaction_id :integer not null +# molecule_id :integer not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# deleted_at :datetime +# + + +module Chemscanner + class ReactionsReagentMolecule < ReactionsMolecule + # STI: this file is only here because of rails model autoloading. + # place all code in app/models/chemscanner/reactions_molecule.rb. + end +end diff --git a/app/models/chemscanner/reactions_solvent_molecule.rb b/app/models/chemscanner/reactions_solvent_molecule.rb new file mode 100644 index 000000000..8823e496b --- /dev/null +++ b/app/models/chemscanner/reactions_solvent_molecule.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: chemscanner_reactions_molecules +# +# id :integer not null, primary key +# reaction_id :integer not null +# molecule_id :integer not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# deleted_at :datetime +# + + +module Chemscanner + class ReactionsSolventMolecule < ReactionsMolecule + # STI: this file is only here because of rails model autoloading. + # place all code in app/models/chemscanner/reactions_molecule.rb. + end +end diff --git a/app/models/chemscanner/scheme.rb b/app/models/chemscanner/scheme.rb new file mode 100644 index 000000000..684cb6706 --- /dev/null +++ b/app/models/chemscanner/scheme.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: chemscanner_schemes +# +# id :integer not null, primary key +# source_id :integer not null +# is_approved :boolean default(FALSE) +# extended_metadata :jsonb +# index :integer default(0) +# image_data :string default("") +# version :string default("") +# created_by :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# deleted_at :datetime +# + + +module Chemscanner + # Hold ChemScanner output information + class Scheme < ApplicationRecord + acts_as_paranoid + + belongs_to :creator, foreign_key: :created_by, class_name: 'User' + belongs_to :source, foreign_key: :source_id, class_name: 'Chemscanner::Source' + + has_many :reactions, + class_name: 'Reaction', foreign_key: :scheme_id, + dependent: :destroy + + has_many :molecules, + class_name: 'Molecule', foreign_key: :scheme_id, + dependent: :destroy + + accepts_nested_attributes_for :reactions, :molecules + + scope :for_user, ->(user_id) { where('created_by = ?', user_id) } + + def file_uuid + source.file_uuid + end + + def empty? + reactions.empty? && molecules.empty? + end + + def add_doi(doi) + return if doi.empty? + + ext_data = extended_metadata + return if ext_data.key?(:doi) + + update!(extended_metadata: ext_data.merge(doi: doi.strip)) + end + + def destroy_version(version) + destroy if self.version == version + end + + def approve(val) + reactions.update_all(is_approved: val) + molecules.update_all(is_approved: val) + + { + scheme_ids: [id], + reaction_ids: reactions.map(&:id), + molecule_ids: molecules.map(&:id) + } + end + + class << self + def save_png(png_list) + png_list.each do |file_info| + id = file_info['id'] + scheme = find(id) + next if scheme.nil? + + scheme.image_data = file_info['imageData'] + scheme.save! + end + end + # handle_asynchronously :save_png + end + end +end diff --git a/app/models/chemscanner/source.rb b/app/models/chemscanner/source.rb new file mode 100644 index 000000000..87fdff9dc --- /dev/null +++ b/app/models/chemscanner/source.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: chemscanner_sources +# +# id :integer not null, primary key +# parent_id :integer +# file_id :integer not null +# extended_metadata :jsonb +# created_by :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# + + +# Storing uploaded file for ChemScanner processing +module Chemscanner + # Uploaded files for scanning + class Source < ActiveRecord::Base + belongs_to :creator, foreign_key: :created_by, class_name: 'User' + + belongs_to :file, -> { where attachable_type: 'ChemscannerSource' }, + class_name: 'Attachment', foreign_key: :file_id, + foreign_type: :attachable_type + + # has_one :file, as: :attachable + + has_many :schemes, + foreign_key: :source_id, class_name: 'Scheme', + dependent: :destroy + + accepts_nested_attributes_for :schemes + + has_many :reactions, through: :schemes + has_many :molecules, through: :schemes + + has_closure_tree dependent: :destroy + + # _ct from closure_tree gem + scope :full_tree, -> { _ct.default_tree_scope(all) } + + scope :for_user, ->(user_id) { where('created_by = ?', user_id) } + + MIME_TYPE = { + cdx: 'chemical/cdx', + cdxml: 'text/xml', + xml: 'text/xml', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + json: 'application/json' + }.freeze + + def file_uuid + file.identifier + end + + def scan + filename = file.filename + extn = File.extname(filename).downcase + + func_name = "#{extn[1..-1]}_process".to_sym + return unless Chemscanner::Process.respond_to?(func_name) + + Chemscanner::Process.send(func_name, self) + end + + def create_child(child_path) + user_id = created_by + source = self.class.new(created_by: user_id) + ext = File.extname(child_path) + basename = File.basename(child_path) + + source.build_file( + filename: basename, + file_path: child_path, + identifier: SecureRandom.uuid, + created_by: user_id, + created_for: user_id, + attachable_type: 'ChemscannerSource', + content_type: MIME_TYPE[ext.to_sym] + ) + source.parent = self + children << source + + source + end + + def approve(val) + reactions.update_all(is_approved: val) + molecules.update_all(is_approved: val) + + file_ids = [id] + scheme_ids = schemes.map(&:id) + reaction_ids = reactions.map(&:id) + molecule_ids = molecules.map(&:id) + + children.each do |child| + info = child.approve(val) + + file_ids.concat(info[:file_ids] || []) + scheme_ids.concat(info[:scheme_ids] || []) + reaction_ids.concat(info[:reaction_ids] || []) + molecule_ids.concat(info[:molecule_ids] || []) + end + + { + file_ids: file_ids, + scheme_ids: scheme_ids, + reaction_ids: reaction_ids, + molecule_ids: molecule_ids + } + end + + class << self + def create_from_uploaded_file(file, uid, user) + user_id = user.nil? ? nil : user.id + temp_file = file['tempfile'] + file_path = temp_file.to_path + extn = File.extname(file_path)[1..-1] + + source = Source.new(created_by: user_id) + + source.build_file( + filename: file['filename'], + file_path: file_path, + identifier: uid, + created_by: user_id, + created_for: user_id, + attachable_type: 'ChemscannerSource', + content_type: MIME_TYPE[extn.to_sym] || '' + ) + + source + end + end + end +end diff --git a/app/models/collaborator.rb b/app/models/collaborator.rb new file mode 100644 index 000000000..7b6626dfa --- /dev/null +++ b/app/models/collaborator.rb @@ -0,0 +1,55 @@ +# == Schema Information +# +# Table name: users +# +# id :integer not null, primary key +# email :string default(""), not null +# encrypted_password :string default(""), not null +# reset_password_token :string +# reset_password_sent_at :datetime +# remember_created_at :datetime +# sign_in_count :integer default(0), not null +# current_sign_in_at :datetime +# last_sign_in_at :datetime +# current_sign_in_ip :inet +# last_sign_in_ip :inet +# created_at :datetime not null +# updated_at :datetime not null +# name :string +# first_name :string not null +# last_name :string not null +# deleted_at :datetime +# counters :hstore not null +# name_abbreviation :string(12) +# type :string default("Person") +# reaction_name_prefix :string(3) default("R") +# layout :hstore not null +# confirmation_token :string +# confirmed_at :datetime +# confirmation_sent_at :datetime +# unconfirmed_email :string +# selected_device_id :integer +# failed_attempts :integer default(0), not null +# unlock_token :string +# locked_at :datetime +# account_active :boolean +# matrix :integer default(0) +# providers :jsonb +# +# Indexes +# +# index_users_on_confirmation_token (confirmation_token) UNIQUE +# index_users_on_deleted_at (deleted_at) +# index_users_on_email (email) UNIQUE +# index_users_on_name_abbreviation (name_abbreviation) UNIQUE WHERE (name_abbreviation IS NOT NULL) +# index_users_on_reset_password_token (reset_password_token) UNIQUE +# index_users_on_unlock_token (unlock_token) UNIQUE +# + +class Collaborator < User + def send_on_create_confirmation_instructions + + end + end + + diff --git a/app/models/collection.rb b/app/models/collection.rb index f04251b92..cb48c1007 100644 --- a/app/models/collection.rb +++ b/app/models/collection.rb @@ -33,6 +33,7 @@ class Collection < ApplicationRecord acts_as_paranoid belongs_to :user, optional: true has_ancestry + include Publishing has_many :collections_samples, dependent: :destroy has_many :collections_reactions, dependent: :destroy @@ -51,6 +52,8 @@ class Collection < ApplicationRecord has_many :sync_collections_users, foreign_key: :collection_id, dependent: :destroy has_many :shared_users, through: :sync_collections_users, source: :user + has_one :doi, as: :doiable + # A collection is locked if it is not allowed to rename or rearrange it scope :unlocked, -> { where(is_locked: false) } scope :locked, -> { where(is_locked: true) } @@ -72,6 +75,55 @@ class Collection < ApplicationRecord default_scope { ordered } + def self.public_collection_id + ENV['PUBLIC_COLL_ID']&.to_i + end + + def self.public_collection + find_by(id: ENV['PUBLIC_COLL_ID']) || find_by( + user_id: User.chemotion_user.id, + label: ENV['PUBLIC_COLL'] + ) + end + + def self.scheme_only_reactions_collection + find_by(id: ENV['SCHEME_ONLY_REACTIONS_COLL_ID']) || find_by( + user_id: User.chemotion_user.id, + label: ENV['SCHEME_ONLY_REACTIONS_COLL'] + ) || find_by( + user_id: User.chemotion_user.id, + label: 'Scheme-only reactions' + ) + end + + def self.scheme_only_reactions_collection_id + ENV['SCHEME_ONLY_REACTIONS_COLL_ID']&.to_i + end + + def self.embargo_accepted_collection + find_by( + user_id: User.chemotion_user.id, + label: 'Embargo Accepted', + is_synchronized: true + ) + end + + def self.element_to_review_collection + where( + user_id: User.chemotion_user.id, + label: 'Element To Review', + is_synchronized: true + ) + end + + def self.reviewed_collection + where( + user_id: User.chemotion_user.id, + label: 'Reviewed', + is_synchronized: true + ) + end + def self.get_all_collection_for_user(user_id) find_by(user_id: user_id, label: 'All', is_locked: true) end @@ -141,4 +193,23 @@ def self.reject_shared(user_id, collection_id) Collection.where(id: collection_id, user_id: user_id, is_shared: true) .each(&:destroy) end + + def self.all_embargos(user_id) + if user_id.nil? + Collection.where( + <<~SQL + id in (select c2.id from collections c2 where c2.ancestry in (select c.id::text from collections c where c.label = 'Published Elements')) + SQL + ) + else + Collection.where( + <<~SQL + id in ( + select c2.id from collections c2 where c2.ancestry in (select c.id::text from collections c where c.label = 'Published Elements') union + select co.id from collections co where co.ancestry in (select c.id::text from collections c, sync_collections_users scu + where c.label = 'Embargoed Publications' and scu.collection_id = c.id and scu.user_id = #{user_id})) + SQL + ) + end + end end diff --git a/app/models/collections_reaction.rb b/app/models/collections_reaction.rb index e78683c4e..08e4c3720 100644 --- a/app/models/collections_reaction.rb +++ b/app/models/collections_reaction.rb @@ -37,6 +37,8 @@ def self.remove_in_collection(reaction_ids, collection_ids) def self.move_to_collection(reaction_ids, from_col_ids, to_col_ids) # Get associated samples sample_ids = Reaction.get_associated_samples(reaction_ids) + # exclude samples which are already public, they are not supposed to be moved + sample_ids = sample_ids.difference(Collection.public_collection.samples.where(id: sample_ids).pluck(:id)) # Delete reactions from collection delete_in_collection(reaction_ids, from_col_ids) # Move associated samples in current collection diff --git a/app/models/compound_open_data.rb b/app/models/compound_open_data.rb new file mode 100644 index 000000000..1a5074375 --- /dev/null +++ b/app/models/compound_open_data.rb @@ -0,0 +1,21 @@ +# == Schema Information +# +# Table name: compound_open_data_locals +# +# x_id :integer +# x_sample_id :integer +# x_data :jsonb +# x_created_at :datetime +# x_updated_at :datetime +# x_inchikey :string +# x_sum_formular :string +# x_cano_smiles :string +# x_external_label :string +# x_short_label :string +# x_name :string +# x_stereo :jsonb +# + +class CompoundOpenData < ApplicationRecord + default_scope { order(x_created_at: :desc) } +end diff --git a/app/models/concerns/attachment_converter.rb b/app/models/concerns/attachment_converter.rb index 2842081ab..758d7b727 100644 --- a/app/models/concerns/attachment_converter.rb +++ b/app/models/concerns/attachment_converter.rb @@ -14,8 +14,8 @@ def init_converter def exec_converter return if !Rails.configuration.try(:converter).try(:url) || !ACCEPTED_FORMATS.include?(File.extname(filename&.downcase)) || aasm_state != 'queueing' - resp = Analyses::Converter.jcamp_converter(id) - self.aasm_state = resp.success? ? 'done' : 'failure' + state = Analyses::Converter.jcamp_converter(id) + self.aasm_state = state if %w[done failure].include?(state) end end end diff --git a/app/models/concerns/attachment_jcamp_aasm.rb b/app/models/concerns/attachment_jcamp_aasm.rb index 642ffda3e..c7dfc8114 100644 --- a/app/models/concerns/attachment_jcamp_aasm.rb +++ b/app/models/concerns/attachment_jcamp_aasm.rb @@ -3,7 +3,7 @@ # - - - - - - - - - - - - - - - - - - - - - - - - - - - - # State machine for attachment Jcamp handle module AttachmentJcampAasm - FILE_EXT_SPECTRA = %w[dx jdx jcamp mzml mzxml raw cdf zip].freeze + FILE_EXT_SPECTRA = %w[dx jdx jcamp mzml mzxml raw cdf zip nmrium].freeze extend ActiveSupport::Concern @@ -15,7 +15,7 @@ module AttachmentJcampAasm aasm do state :idle, initial: true state :queueing, :regenerating, :done - state :peaked, :edited, :backup, :image, :json + state :peaked, :edited, :backup, :image, :json, :csv, :nmrium state :failure state :non_jcamp state :oo_editing @@ -29,25 +29,25 @@ module AttachmentJcampAasm end event :set_queueing do - transitions from: %i[idle done backup failure non_jcamp queueing regenerating], + transitions from: %i[idle done backup failure non_jcamp queueing regenerating nmrium], to: :queueing end event :set_regenerating do - transitions from: %i[idle done backup failure non_jcamp queueing regenerating], + transitions from: %i[idle done backup failure non_jcamp queueing regenerating nmrium], to: :regenerating end event :set_force_peaked do - transitions from: %i[queueing regenerating], to: :peaked + transitions from: %i[queueing regenerating nmrium], to: :peaked end event :set_edited do - transitions from: %i[peaked queueing regenerating], to: :edited + transitions from: %i[peaked queueing regenerating nmrium], to: :edited end event :set_backup do - transitions from: %i[peaked edited failure], to: :backup + transitions from: %i[peaked edited failure nmrium], to: :backup end event :set_non_jcamp do @@ -55,7 +55,7 @@ module AttachmentJcampAasm end event :set_done do - transitions from: %i[queueing regenerating], to: :done + transitions from: %i[queueing regenerating nmrium], to: :done end event :set_image do @@ -63,11 +63,19 @@ module AttachmentJcampAasm end event :set_json do - transitions from: %i[idle peaked non_jcamp], to: :json + transitions from: %i[idle peaked non_jcamp json], to: :json + end + + event :set_csv do + transitions from: %i[idle peaked non_jcamp], to: :csv + end + + event :set_nmrium do + transitions from: %i[idle peaked edited non_jcamp queueing regenerating], to: :nmrium end event :set_failure do - transitions from: %i[idle queueing regenerating failure], to: :failure + transitions from: %i[idle queueing regenerating failure nmrium], to: :failure end end end @@ -103,8 +111,8 @@ def require_peaks_generation? # rubocop:disable all is_peak_edit = %w[peak edit].include?(typname) return generate_img_only(typname) if is_peak_edit - generate_spectrum(true, false) if queueing? && !new_upload - generate_spectrum(true, true) if regenerating? && !new_upload + generate_spectrum(true, false) if queueing? + generate_spectrum(true, true) if regenerating? end def belong_to_analysis? @@ -122,6 +130,7 @@ def generate_att(meta_tmp, addon, to_edit = false, ext = nil) meta_filename = Chemotion::Jcamp::Gen.filename(filename_parts, addon, ext) content_type = ext == 'png' ? 'image/png' : 'application/octet-stream' + att = Attachment.new( filename: meta_filename, file_path: meta_tmp.path, @@ -135,7 +144,12 @@ def generate_att(meta_tmp, addon, to_edit = false, ext = nil) att.set_edited if ext != 'png' && to_edit att.set_image if ext == 'png' att.set_json if ext == 'json' + att.set_csv if ext == 'csv' + att.set_nmrium if ext == 'nmrium' att.update!(storage: Rails.configuration.storage.primary_store) + # att.update!( + # attachable_id: attachable_id, attachable_type: 'Container' + # ) att end @@ -152,6 +166,14 @@ def generate_json_att(json_tmp, addon, to_edit = false) generate_att(json_tmp, addon, to_edit, 'json') end + def generate_csv_att(csv_tmp, addon, to_edit = false) + generate_att(csv_tmp, addon, to_edit, 'csv') + end + + def generate_nmrium_att(nmrium_tmp, addon, to_edit = false) + generate_att(nmrium_tmp, addon, to_edit, 'nmrium') + end + def build_params(params = {}) _, extname = extension_parts params[:mass] = 0.0 @@ -163,15 +185,17 @@ def build_params(params = {}) params end + # TODO: Fix bugs and improve code def get_infer_json_content - atts = Attachment.where(attachable_id: attachable_id) + atts = Attachment.where(attachable_id: attachable_id) # might break on multiple Attachments with the same ID but different types + # shorten this whole block to a single find with '{}' fallback if none is found infers = atts.map do |att| keyword, _extname = att.extension_parts keep = att.json? && keyword == 'infer' keep ? att : nil end.select(&:present?) - !infers.empty? ? infers[0].read_file : '{}' + infers.empty? ? '{}' : infers[0].read_file end def update_prediction(params, spc_type, is_regen) @@ -184,38 +208,87 @@ def update_prediction(params, spc_type, is_regen) def create_process(is_regen) params = build_params - tmp_jcamp, tmp_img, spc_type = Tempfile.create('molfile') do |t_molfile| - if attachable&.root_element.is_a?(Sample) - t_molfile.write(attachable.root_element.molecule.molfile) - t_molfile.rewind - end - Chemotion::Jcamp::Create.spectrum( - abs_path, t_molfile.path, is_regen, params - ) + + if params[:ext] == 'nmrium' + return generate_spectrum_from_nmrium + end + + tmp_jcamp, tmp_img, arr_jcamp, arr_img, arr_csv, arr_nmrium, spc_type, invalid_molfile = generate_spectrum_data(params, is_regen) + + check_invalid_molfile(invalid_molfile) + + if spc_type == 'bagit' + read_bagit_data(arr_jcamp, arr_img, arr_csv, spc_type, is_regen) + elsif arr_jcamp.length > 1 + read_processed_data(arr_jcamp, arr_img, spc_type, is_regen) + else + jcamp_att = generate_jcamp_att(tmp_jcamp, 'peak') + jcamp_att.auto_infer_n_clear_json(spc_type, is_regen) + img_att = generate_img_att(tmp_img, 'peak') + + tmp_files_to_be_deleted = [tmp_jcamp, tmp_img] + tmp_files_to_be_deleted.push(*arr_img) + + set_done + delete_tmps(tmp_files_to_be_deleted) + delete_related_imgs(img_att) + delete_edit_peak_after_done + + jcamp_att + end + end + + def edit_process(is_regen, orig_params) + params = build_params(orig_params) + tmp_jcamp, tmp_img, _, _, arr_csv, arr_nmrium, spc_type, invalid_molfile = generate_spectrum_data(params, is_regen) + + check_invalid_molfile(invalid_molfile) + + jcamp_att = generate_jcamp_att(tmp_jcamp, 'edit', true) + jcamp_att.update_prediction(params, spc_type, is_regen) + img_att = generate_img_att(tmp_img, 'edit', true) + + tmp_files_to_be_deleted = [tmp_jcamp, tmp_img] + + unless arr_csv.nil? || arr_csv.length == 0 + curr_tmp_csv = arr_csv[0] + csv_att = generate_csv_att(curr_tmp_csv, 'edit', false) + tmp_files_to_be_deleted.push(*arr_csv) + delete_related_csv(csv_att) end - if tmp_img.nil? && spc_type.nil? && tmp_jcamp['invalid_molfile'] == true + # set_backup + unless arr_nmrium.nil? || arr_nmrium.length == 0 + curr_tmp_nmrium = arr_nmrium[0] + nmrium_att = generate_nmrium_att(curr_tmp_nmrium, '', false) + tmp_files_to_be_deleted.push(*arr_nmrium) + delete_related_nmrium(nmrium_att) + end + + set_backup + delete_tmps(tmp_files_to_be_deleted) + delete_related_imgs(img_att) + delete_related_edit_peak(jcamp_att) + jcamp_att + end + + def check_invalid_molfile(invalid_molfile = false) + if invalid_molfile == true # add message when invalid molfile Message.create_msg_notification( channel_subject: Channel::CHEM_SPECTRA_NOTIFICATION, message_from: attachable.root_element.created_by, - data_args: { 'msg': 'Invalid molfile' } + data_args: { msg: 'Invalid molfile' }, ) end - - jcamp_att = generate_jcamp_att(tmp_jcamp, 'peak') - jcamp_att.auto_infer_n_clear_json(spc_type, is_regen) - img_att = generate_img_att(tmp_img, 'peak') - set_done - delete_tmps([tmp_jcamp, tmp_img]) - delete_related_imgs(img_att) - delete_edit_peak_after_done - jcamp_att end - def edit_process(is_regen, orig_params) - params = build_params(orig_params) - tmp_jcamp, tmp_img, spc_type = Tempfile.create('molfile') do |t_molfile| + def generate_spectrum_data(params, is_regen) + if params[:ext] == 'nmrium' + return + end + + tmp_jcamp, tmp_img, arr_jcamp, arr_img, arr_csv, arr_nmrium, spc_type, invalid_molfile = Tempfile.create('molfile') do |t_molfile| if attachable&.root_element.is_a?(Sample) t_molfile.write(attachable.root_element.molecule.molfile) t_molfile.rewind @@ -224,33 +297,105 @@ def edit_process(is_regen, orig_params) abs_path, t_molfile.path, is_regen, params ) end + end - if tmp_img.nil? && spc_type.nil? && tmp_jcamp['invalid_molfile'] == true - # add message when invalid molfile - Message.create_msg_notification( - channel_subject: Channel::CHEM_SPECTRA_NOTIFICATION, - message_from: attachable.root_element.created_by, - data_args: { 'msg': 'Invalid molfile' } - ) + def generate_spectrum(is_create = false, is_regen = false, params = {}) + is_create ? create_process(is_regen) : edit_process(is_regen, params) + rescue StandardError => e + set_failure + Rails.logger.info('**** Jcamp Peaks Generation fails ***') + Rails.logger.error(e) + end + + def read_processed_data(arr_jcamp, arr_img, spc_type, is_regen) + jcamp_att = nil + tmp_to_be_deleted = [] + tmp_img_to_deleted = [] + arr_jcamp.each_with_index do |jcamp, idx| + file_name_to_generate = idx == 0 ? 'peak' : "processed_#{idx}" + + curr_jcamp_att = generate_jcamp_att(jcamp, file_name_to_generate) + curr_jcamp_att.auto_infer_n_clear_json(spc_type, is_regen) + jcamp_att = curr_jcamp_att if idx == 0 + + curr_tmp_img = arr_img[idx] + img_att = generate_img_att(curr_tmp_img, file_name_to_generate) + + tmp_to_be_deleted.push(jcamp, curr_tmp_img) + tmp_img_to_deleted.push(img_att) end - jcamp_att = generate_jcamp_att(tmp_jcamp, 'edit', true) - jcamp_att.update_prediction(params, spc_type, is_regen) - img_att = generate_img_att(tmp_img, 'edit', true) - set_backup - delete_tmps([tmp_jcamp, tmp_img]) - delete_related_imgs(img_att) + + set_done + delete_tmps(tmp_to_be_deleted) + delete_related_arr_img(tmp_img_to_deleted) delete_edit_peak_after_done jcamp_att end - def generate_spectrum(is_create = false, is_regen = false, params = {}) - is_create ? create_process(is_regen) : edit_process(is_regen, params) + def read_bagit_data(arr_jcamp, arr_img, arr_csv, spc_type, is_regen) + jcamp_att = nil + tmp_to_be_deleted = [] + tmp_img_to_deleted = [] + arr_jcamp.each_with_index do |jcamp, idx| + curr_jcamp_att = generate_jcamp_att(jcamp, "#{idx + 1}_bagit") + curr_jcamp_att.auto_infer_n_clear_json(spc_type, is_regen) + curr_tmp_img = arr_img[idx] + img_att = generate_img_att(curr_tmp_img, "#{idx + 1}_bagit") + tmp_to_be_deleted.push(jcamp, curr_tmp_img) + tmp_img_to_deleted.push(img_att) + + curr_tmp_csv = arr_csv[idx] + _ = generate_csv_att(curr_tmp_csv, "#{idx + 1}_bagit") + tmp_to_be_deleted.push(curr_tmp_csv) + + jcamp_att = curr_jcamp_att if idx == 0 + end + set_done + delete_tmps(tmp_to_be_deleted) + delete_related_arr_img(tmp_img_to_deleted) + delete_edit_peak_after_done + jcamp_att + end + + def generate_spectrum_from_nmrium + tmp_jcamp = Chemotion::Jcamp::CreateFromNMRium.jcamp_from_nmrium(abs_path) + jcamp_att = generate_jcamp_att(tmp_jcamp, 'edit', true) + + set_nmrium + + tmp_files_to_be_deleted = [tmp_jcamp] + delete_tmps(tmp_files_to_be_deleted) + delete_related_edited_jcamp(jcamp_att) + delete_related_edit_peak_with_att(jcamp_att) + delete_related_nmrium(self) + jcamp_att rescue StandardError => e set_failure - Rails.logger.info('**** Jcamp Peaks Generation fails ***') + Rails.logger.info('**** Jcamp Edit from NMRium Generation fails ***') Rails.logger.error(e) end + def delete_related_edit_peak_with_att(attachment) + return unless attachment + + atts = Attachment.where(attachable_id: attachable_id) + valid_name = fname_wo_ext(self) + atts.each do |att| + edit_jdx_name = File.basename(att.filename, '.edit.jdx') + peak_jdx_name = File.basename(att.filename, '.peak.jdx') + edit_image_name = File.basename(att.filename, '.edit.png') + peak_image_name = File.basename(att.filename, '.peak.png') + array_valid_names = [edit_jdx_name, peak_jdx_name, edit_image_name, peak_image_name] + + is_delete = ( + (att.edited? || att.peaked? || att.image?) && + att.id != attachment.id && + (array_valid_names.include? valid_name) + ) + att.delete if is_delete + end + end + def delete_tmps(tmp_arr) tmp_arr.each do |tmp| next unless tmp @@ -265,9 +410,24 @@ def delete_edit_peak_after_done destroy if %w[edit peak].include?(typname) end + def delete_related_edit_peak(jcamp_att) + return unless jcamp_att + + atts = Attachment.where(attachable_id: attachable_id) + valid_name = fname_wo_ext(self) + atts.each do |att| + is_delete = ( + (att.edited? || att.peaked?) && + att.id != jcamp_att.id && + valid_name == fname_wo_ext(att) + ) + att.delete if is_delete + end + end + def fname_wo_ext(target) parts = target.filename_parts - ending = parts.length == 2 ? -2 : -3 + ending = parts.length == 2 || parts.length == 3 ? -2 : -3 parts[0..ending].join('_') end @@ -286,6 +446,70 @@ def delete_related_imgs(img_att) end end + def delete_related_arr_img(arr_img) + return unless arr_img + + arr_img.each do |img_att| + next unless img_att + + atts = Attachment.where(attachable_id: attachable_id) + valid_name = fname_wo_ext(img_att) + atts.each do |att| + is_delete = ( + att.image? && + att.id != img_att.id && + valid_name == fname_wo_ext(att) + ) + att.delete if is_delete + end + end + end + + def delete_related_csv(csv_att) + return unless csv_att + + atts = Attachment.where(attachable_id: attachable_id) + valid_name = fname_wo_ext(self) + atts.each do |att| + is_delete = ( + att.csv? && + att.id != csv_att.id && + valid_name == fname_wo_ext(att) + ) + att.delete if is_delete + end + end + + def delete_related_nmrium(nmrium_att) + return unless nmrium_att + + atts = Attachment.where(attachable_id: attachable_id) + valid_name = filename_parts[0] + atts.each do |att| + is_delete = ( + att.nmrium? && + att.id != nmrium_att.id && + valid_name == fname_wo_ext(att) + ) + att.delete if is_delete + end + end + + def delete_related_edited_jcamp(jcamp_att) + return unless jcamp_att + + atts = Attachment.where(attachable_id: jcamp_att.attachable_id) + valid_name = fname_wo_ext(self) + atts.each do |att| + is_delete = ( + att.edited? && + att.id != jcamp_att.id && + valid_name == att.filename_parts[0] + ) + att.delete if is_delete + end + end + def generate_img_only(typname) _, tmp_img = Chemotion::Jcamp::CreateImg.spectrum_img_gene(abs_path) img_att = generate_img_att(tmp_img, typname) @@ -326,7 +550,7 @@ def infer_base_on_type(t_molfile, params) params[:layout], params[:peaks] || '[]', params[:shift] || '{}', - t_spectrum + t_spectrum, ) end end diff --git a/app/models/concerns/metadata_jsonld.rb b/app/models/concerns/metadata_jsonld.rb new file mode 100644 index 000000000..1cc8dba33 --- /dev/null +++ b/app/models/concerns/metadata_jsonld.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true +require 'json' +require 'open-uri' +require 'nokogiri' + +module MetadataJsonld + extend ActiveSupport::Concern + + def json_ld + if element_type == 'Sample' + json_ld_sample_root + elsif element_type == 'Reaction' + json_ld_reaction + elsif element_type == 'Container' + json_ld_container + end + end + + def json_ld_sample_root(pub = self) + json = json_ld_study + json['about'] = [json_ld_sample] + json + end + + def json_ld_study(pub = self) + json = {} + json['@context'] = 'https://schema.org' + json['@type'] = 'Study' + json['@id'] = "https://doi.org/#{doi.full_doi}" + json['dct:conformsTo'] = { + "@id": 'https://bioschemas.org/profiles/Study/0.3-DRAFT', + "@type": 'CreativeWork' + } + json['publisher'] = json_ld_publisher + json['dateCreated'] = pub.published_at&.strftime('%Y-%m-%d') + json['datePublished'] = pub.published_at&.strftime('%Y-%m-%d') + json['author'] = json_ld_authors(pub.taggable_data) + json['contributor'] = json_ld_contributor(pub.taggable_data["contributors"]) + json['citation'] = json_ld_citations(pub.element.literatures, pub.element.id) + json['includedInDataCatalog'] = json_ld_data_catalog(pub) + json + end + + def json_ld_data_catalog(pub = self) + json = {} + json['@context'] = 'https://schema.org' + json['@type'] = 'DataCatalog' + json['@id'] = 'https://www.chemotion-repository.net' + json['dct:conformsTo'] = { + "@id": 'https://bioschemas.org/profiles/DataCatalog/0.3-RELEASE-2019_07_01', + "@type": 'CreativeWork' + } + json['description'] = 'Chemotion Repository' + json['name'] = 'Chemotion Repository' + json['url'] = 'https://www.chemotion-repository.net' + json + + end + + def conforms_to + { + "@id": "https://bioschemas.org/profiles/Study/0.3-DRAFT", + "@type": "CreativeWork" + } + end + + def json_ld_sample(pub = self) + # metadata_xml + json = {} + json['@context'] = 'https://schema.org' + json['@type'] = 'ChemicalSubstance' + json['@id'] = "https://doi.org/#{pub.doi.full_doi}" + json['identifier'] = "CRS-#{pub.id}" + json['url'] = "https://www.chemotion-repository.net/inchikey/#{pub.doi.suffix}" + json['name'] = pub.element.molecule_name&.name + json['alternateName'] = pub.element.molecule.inchistring + # json['image'] = element.sample_svg_file + json['image'] = 'https://www.chemotion-repository.net/images/samples/' + pub.element.sample_svg_file if pub&.element&.sample_svg_file.present? + json['description'] = json_ld_description(pub.element.description) + #json['author'] = json_ld_authors(pub.taggable_data) + json['hasBioChemEntityPart'] = json_ld_moelcule_entity(pub) + json['subjectOf'] = json_ld_subjectOf(pub) + #json_object = JSON.parse(json) + #JSON.pretty_generate(json_object) + json + # formatted_json = JSON.pretty_generate(json) + # formatted_json + end + + + def json_ld_reaction + json = {} + json['@context'] = 'https://schema.org' + json['@type'] = 'Study' + json['@id'] = "https://doi.org/#{doi.full_doi}" + json['identifier'] = "CRR-#{id}" + json['url'] = "https://www.chemotion-repository.net/inchikey/#{doi.suffix}" + json['additionalType'] = 'Reaction' + json['name'] = element.rinchi_short_key + json['author'] = json_ld_authors(taggable_data) + json['description'] = json_ld_description(element.description) + json['license'] = rights_data[:rightsURI] + json['datePublished'] = published_at&.strftime('%Y-%m-%d') + json['dateCreated'] = created_at&.strftime('%Y-%m-%d') + json['publisher'] = json_ld_publisher + json['provider'] = json_ld_publisher + json['keywords'] = 'chemical reaction: structures conditions' + json['citation'] = json_ld_citations(element.literatures, element.id) + json['subjectOf'] = json_ld_reaction_has_part + json + end + + def json_ld_embargo + json = {} + json['@context'] = 'https://schema.org' + json['@type'] = 'Study' + # json['startDate'] = embargo_start_date&.strftime('%Y-%m-%d') + json + end + + def json_ld_lab_protocol + json = {} + json['@context'] = 'https://schema.org' + json['@type'] = 'LabProtocol' + json['@id'] = "https://doi.org/#{doi.full_doi}" + json + end + + def json_ld_reaction_has_part + json = [] + children&.each do |pub| + json.push(json_ld_sample(pub)) if pub.element_type == 'Sample' + json.push(json_ld_analysis(pub)) if pub.element_type == 'Container' + end + json + end + + def json_ld_reaction_has_part_product + end + + def json_ld_description(desc) + REXML::Text.new(Nokogiri::HTML( Chemotion::QuillToHtml.new.convert(desc.to_json)).text, false, nil, false).to_s + #persit_datacite_metadata_xml! unless metadata_xml.present? + #xml_data = Nokogiri::XML(metadata_xml) + #desc = xml_data.search('description')&.text&.strip + #desc + end + + def json_ld_container + json_ld_analysis + end + + + def json_ld_subjectOf(pub = self) + arr = [] + # arr.push(json_ld_creative_work(pub)) + pub.children&.each do |ana| + arr.push(json_ld_analysis(ana)) + end + arr + end + + def json_ld_analysis(pub = self) + json = {} + json['@context'] = 'https://schema.org' + json['@type'] = 'Dataset' + json['@id'] = "https://doi.org/#{pub.doi.full_doi}" + json['identifier'] = "CRD-#{pub.id}" + json['url'] = "https://www.chemotion-repository.net/inchikey/#{pub.doi.suffix}" + json['name'] = pub.element.extended_metadata['kind'] || '' if pub&.element&.extended_metadata.present? + json['author'] = json_ld_authors(pub.taggable_data) + json['description'] = json_ld_analysis_description(pub) + json + end + + def json_ld_analysis_description(pub) + #xml_data = Nokogiri::XML(metadata_xml) + #desc = xml_data.search('description')&.text&.strip + #desc + element = pub.element + kind = 'dataset for ' + (element.extended_metadata['kind'] || '')&.split('|').pop + '\n' + desc = element.extended_metadata['description'] || '' + '\n' + content = REXML::Text.new(Nokogiri::HTML( Chemotion::QuillToHtml.new.convert(element.extended_metadata['content'] || '')).text, false, nil, false).to_s + + kind + desc + content + end + + + def json_ld_citations(literatures, id) + json = [] + literatures.each do |lit| + json.push(json_ld_citation(lit, id)) + end + json + end + + def json_ld_citation(lit, id) + json = {} + json['@type'] = 'CreativeWork' + bib = lit[:refs] && lit[:refs]['bibtex'] + bb = DataCite::LiteraturePaser.parse_bibtex!(bib, id) + bb = DataCite::LiteraturePaser.get_metadata(bb, lit[:doi], id) unless bb.class == BibTeX::Entry + dc_lit = DataCite::LiteraturePaser.report_hash(lit, bb) if bb.class == BibTeX::Entry + json['name'] = dc_lit[:title] unless dc_lit.blank? + json['author'] = dc_lit[:author] unless dc_lit.blank? + json['url'] = dc_lit[:url] unless dc_lit.blank? + json + end + + def json_ld_publisher + json = {} + json['@type'] = 'Organization' + json['name'] = 'chemotion-repository' + json + end + + + def json_ld_authors(taggable_data) + creators = taggable_data["creators"] || [] + arr = [] + creators.each do |author| + json = {} + json['@type'] = 'Person' + json['name'] = author['name'] + json['identifier'] = author['ORCID'] if author['ORCID'].present? + json['familyName'] = author["familyName"] + json['givenName'] = author["givenName"] + json['affiliation'] = json_ld_affiliation(author['affiliationIds']&.first, taggable_data) + arr.push(json) + end + arr + end + + def json_ld_contributor(contributor) + return {} unless contributor.present? + + json = {} + json['@type'] = 'Person' + json['name'] = contributor['name'] + json['identifier'] = contributor['ORCID'] if contributor['ORCID'].present? + json['familyName'] = contributor["familyName"] + json['givenName'] = contributor["givenName"] + # json['affiliation'] = json_ld_affiliation(author['affiliationIds']&.first) + json + end + + def json_ld_affiliation(aff_id, taggable_data) + json = {} + json['@type'] = 'Organization' + json['name'] = taggable_data['affiliations'][aff_id.to_s] + json + end + + def json_ld_molecular_weight(mol) + json ={} + json['@type'] = 'QuantitativeValue' + json['value'] = mol.molecular_weight + json['unitCode'] = 'g/mol' + json + end + + def json_ld_moelcule_entity(pub = self) + mol = pub.element.molecule + json = {} + json['@type'] = 'MolecularEntity' + json['smiles'] = mol.cano_smiles + json['inChIKey'] = mol.inchikey + json['inChI'] = mol.inchistring + json['name'] = pub.element.molecule_name&.name + json['molecularFormula'] = mol.sum_formular + json['molecularWeight'] = json_ld_molecular_weight(mol) + json['iupacName'] = mol.iupac_name + json + end +end diff --git a/app/models/concerns/publishing.rb b/app/models/concerns/publishing.rb new file mode 100644 index 000000000..91f021e0c --- /dev/null +++ b/app/models/concerns/publishing.rb @@ -0,0 +1,334 @@ +module Publishing + class Net::FTP + def puttextcontent(content, remotefile, &block) + f = StringIO.new(content) + begin + storlines('STOR ' + remotefile, f, &block) + ensure + f.close + end + end + end + + extend ActiveSupport::Concern + + included do + + has_one :publication, as: :element + has_one :publication_as_source, as: :original_element + + def publication_tag + self.tag.taggable_data['publication'] + end + + def reserve_suffix + return Doi.create_for_element!(self) unless (d = self.doi) + + if self.is_a?(Sample) && d.inchikey != self.molecule.inchikey + d.update!(doiable: nil) + return Doi.create_for_element!(self) + elsif self.is_a?(Reaction) && (d.inchikey.tr('reaction/','') != self.products_short_rinchikey_trimmed) + d.update!(doiable: nil) + return Doi.create_for_element!(self) + end + d + end + + def reserve_suffix_analyses(as = Container.none) + ids = as.map(&:id) + if self.is_a?(Sample) + ik = self.molecule.inchikey + analysis_set = self.analyses.where(id: ids) + elsif self.is_a?(Reaction) + ik = self.products_short_rinchikey_trimmed + analysis_set = self.analyses.where(id: ids) | + Container.where(id: (self.samples.map(&:analyses).flatten.map(&:id) & ids)) + end + + analysis_set.each do |analysis| + if (doi = analysis.doi) + type = analysis.extended_metadata['kind'].delete(' ') + if (doi&.inchikey != ik) || (type != doi&.analysis_type) + doi.update!(doiable: nil) + doi = Doi.create_for_analysis!(analysis) + end + else + doi = Doi.create_for_analysis!(analysis) + end + end + end + + def full_doi + return nil unless (d = Doi.find_by(doiable: self)) + d.full_doi + end + + def generate_doi(version) + version_str = version.to_i.zero? ? '' : '.' + version.to_s + inchikey_version = self.molecule.inchikey + version_str + "#{Datacite::Mds.new.doi_prefix}/#{inchikey_version}" + end + + def create_publication_tag(contributor, author_ids, license) + authors = User.where(id: author_ids, type: %w(Person Collaborator)) + .includes(:affiliations) + .order("position(users.id::text in '#{author_ids}')") + affiliations = authors.map(&:current_affiliations) + affiliations_output = {} + affiliations.flatten.each do |aff| + affiliations_output[aff.id] = aff.output_full + end + publication = { + published_by: author_ids[0], + author_ids: author_ids, + creators: authors.map { |author| + { + 'givenName' => author.first_name, + 'familyName' => author.last_name, + 'name' => author.name, + 'ORCID' => author.orcid, + 'affiliationIds' => author.current_affiliations.map(&:id), + 'id' => author.id + } + }, + contributors: { + 'givenName' => contributor.first_name, + 'familyName' => contributor.last_name, + 'name' => contributor.name, + 'ORCID' => contributor.orcid, + 'affiliations' => contributor.current_affiliations.map{ |aff| aff.output_full }, + 'id' => contributor.id + }, + affiliations: affiliations_output, + affiliation_ids: affiliations.map { |as| as.map(&:id) }, + queued_at: DateTime.now, + license: license + } + et = self.tag + et.update!( + taggable_data: (et.taggable_data || {}).merge(publication: publication) + ) + end + + ## + # Update the tag['publication'] for a (being) published public sample + + def update_publication_tag(**args) + data = args.slice( + :doi_reg_at, :pubchem_reg_at, :published_at, :sample_version, :doi, :chem_first + ) + et = self.tag + td = et.taggable_data + td['publication'].merge!(data) + + ## update molecule tag + if self.is_a?(Sample) + mt = self.molecule.tag + mt_data = mt.taggable_data || {} + # chemotion_first unless already chemotion_first + mt_data['chemotion'] ||= {} + + if args[:chem_first] && mt_data['chemotion']['chemotion_first'].blank? + mt_data['chemotion']['chemotion_first'] = args[:chem_first] + end + + mt_data['chemotion']['doi'] = args[:doi] unless mt_data['chemotion']['doi'].present? + mt_data['chemotion']['last_published_at'] = args[:published_at] + + mt.update!(taggable_data: mt_data) + end + et.save! + end + + ## + # Update the tag of the original sample (and of its analysis) used for publication + + def tag_as_published(pub_sample, ori_analyses) + et = self.tag + if (pub_sample.is_a? Reaction) + et.update!( + taggable_data: (et.taggable_data || {}).merge(public_reaction: pub_sample.id, publish_pending: true) + ) + else + et.update!( + taggable_data: (et.taggable_data || {}).merge(public_sample: pub_sample.id, publish_pending: true) + ) + end + + ori_analyses.each do |analysis| + analysis.reload + aid = pub_sample.analyses.select { |a| a.extended_metadata == analysis.extended_metadata }&.first&.id || true + at = analysis.tag + at.update!( + taggable_data: (at.taggable_data || {}).merge(public_analysis: aid) + ) + xm = analysis.extended_metadata + xm.delete('publish') + xm['public_analysis'] = aid + analysis.update!(extended_metadata: xm) + end + end + + def tag_as_new_version(previous_element, scheme_only: false) + previous_license = previous_element&.tag&.taggable_data['publication']['license'] + previous_users = previous_element&.tag&.taggable_data['publication']['creators'] + + element_tag = self.tag + element_tag.update!( + taggable_data: (element_tag.taggable_data || {}).merge( + previous_version: { + id: previous_element.id, + doi: { + id: previous_element&.doi&.id + }, + license: previous_license, + scheme_only: scheme_only, + users: previous_users + } + ) + ) + end + + def tag_replace_in_publication + element_tag = self.tag + element_tag.update!( + taggable_data: (element_tag.taggable_data || {}).merge( + replace_in_publication: true + ) + ) + end + + def untag_replace_in_publication + element_tag = self.tag + + taggable_data = element_tag.taggable_data || {} + taggable_data.delete('replace_in_publication') + + element_tag.update!( + taggable_data: taggable_data + ) + end + + def update_versions_tag + element_tag = self.tag + + if element_tag.taggable_data['new_version'].nil? + # recursively find all versions of the latest element + versions = self.find_versions + else + # copy the list of versions from the latest element + new_version = self.class.find_by(id: element_tag.taggable_data['new_version']['id']) + versions = new_version&.tag&.taggable_data['versions'] + end + + element_tag.update!( + taggable_data: (element_tag.taggable_data || {}).merge( + versions: versions + ) + ) + + # call this method recursively for all versions + unless element_tag.taggable_data['previous_version'].nil? + previous_version = self.class.find_by(id: element_tag.taggable_data['previous_version']['id']) + previous_version.update_versions_tag + end + end + + def find_versions + element_tag = self.tag + versions = [self.id] + + unless element_tag.taggable_data['previous_version'].nil? + previous_version = self.class.find_by(id: element_tag.taggable_data['previous_version']['id']) + versions += previous_version.find_versions + end + + return versions + end + + def tag_as_previous_version(new_element) + element_tag = self.tag + element_tag.update!( + taggable_data: (element_tag.taggable_data || {}).merge( + new_version: { + id: new_element.id + }) + ) + end + + def untag_as_previous_version + element_tag = self.tag + taggable_data = element_tag.taggable_data || {} + taggable_data.delete('new_version') + element_tag.update!(taggable_data: taggable_data) + end + + def tag_reserved_suffix(ori_analyses) + et = self.tag + et.update!( + taggable_data: (et.taggable_data || {}).merge(reserved_doi: self.doi.full_doi) + ) + ori_analyses.each do |analysis| + at = analysis.tag + d = analysis.doi.full_doi + at.update!( + taggable_data: (at.taggable_data || {}).merge(reserved_doi: d) + ) + xm = analysis.extended_metadata + xm['reserved_doi'] = d + xm['publish'] = true + analysis.update!(extended_metadata: xm) + end + end + + def untag_reserved_suffix() + et = self.tag + taggable_data = et.taggable_data || {} + taggable_data.delete('reserved_doi') + et.update!(taggable_data: taggable_data) + + self.analyses.each do |analysis| + at = analysis.tag + td = at.taggable_data + td.delete('reserved_doi') + at.update!(taggable_data: td) + + xm = analysis.extended_metadata + xm.delete('reserved_doi') + analysis.update!(extended_metadata: xm) + end + end + + # remove doi link if molecule changes + def check_doi + d = self.doi + return true unless d + if self.is_a?(Sample) && molecule_id_changed? + d.update!(doiable: nil) + self.untag_reserved_suffix + + elsif self.is_a?(Reaction) && (d.inchikey.tr('reaction/', '') != self.products_short_rinchikey_trimmed) + d.update!(doiable: nil) + self.untag_reserved_suffix + self.products.each do |s| + sd = s.doi + sd.update!(doiable: nil) unless sd.nil? + s.untag_reserved_suffix + end + end + true + end + + def get_new_version_short_label + m = self.previous_version.short_label.match /^(.*)-V(\d+)$/ + if m + # increment the version part of the short_label of the previous version + version = Integer(m[2]) + 1 + self.short_label = "#{m[1]}-V#{version}" + else + # append "-V1" to the short_label of the previous version + self.short_label = "#{self.previous_version.short_label}-V1" + end + end + end +end diff --git a/app/models/concerns/reaction_rinchi.rb b/app/models/concerns/reaction_rinchi.rb index 75f47bd20..5cc3a8406 100644 --- a/app/models/concerns/reaction_rinchi.rb +++ b/app/models/concerns/reaction_rinchi.rb @@ -45,11 +45,46 @@ def retrieve_molfiles def no_structure <<~MOLFILE - + ACCLDraw04191619342D - + 0 0 0 0 0 0 0 0 0 0999 V2000 M END MOLFILE end + + def products_rinchis + mols_rcts, mols_prds, mols_agts = self.retrieve_molfiles + rcts = Rinchi::MolVect.new + # mols_rcts.each do |rct| rcts.push(rct) end + [].each do |rct| rcts.push(rct) end + prds = Rinchi::MolVect.new + mols_prds.each do |prd| prds.push(prd) end + agts = Rinchi::MolVect.new + # mols_agts.each do |agt| agts.push(agt) end + [].each do |agt| agts.push(agt) end + Rinchi.convert(rcts, prds, agts) + end + + def no_structure_rinchis + prds = Rinchi::MolVect.new + mols_prds = [] + mols_prds.push(no_structure) + mols_prds.each { |prd| prds.push(prd) } + Rinchi.convert(Rinchi::MolVect.new, prds, Rinchi::MolVect.new) + end + + def products_short_rinchikey + _, _, result, _ = products_rinchis + result + end + + def products_short_rinchikey_trimmed + if products_short_rinchikey.blank? + _, _, result, _ = no_structure_rinchis + result&.sub(/Short-RInChIKey=/, '') + else + products_short_rinchikey.sub(/Short-RInChIKey=/, '') + end + end end diff --git a/app/models/concerns/reaction_sample_collections.rb b/app/models/concerns/reaction_sample_collections.rb index e2b49410b..63601e3a0 100644 --- a/app/models/concerns/reaction_sample_collections.rb +++ b/app/models/concerns/reaction_sample_collections.rb @@ -9,7 +9,14 @@ module ReactionSampleCollections def assign_sample_to_collections self.reaction.collections.each do |c| - CollectionsSample.where(sample: self.sample, collection: c).first_or_create + # same as CollectionsSample.where(sample: self.sample, collection: c).first_or_create + # but should work with act_as_paranoid + collection_sample = CollectionsSample.where(sample: self.sample, collection: c).with_deleted.first + if collection_sample.nil? + CollectionsSample.new(sample: self.sample, collection: c).save! + elsif collection_sample.deleted? + collection_sample.restore + end end end end diff --git a/app/models/concerns/segmentable.rb b/app/models/concerns/segmentable.rb index a77c95ec9..383df68c1 100644 --- a/app/models/concerns/segmentable.rb +++ b/app/models/concerns/segmentable.rb @@ -7,9 +7,38 @@ module Segmentable has_many :segments, as: :element, dependent: :destroy end - def save_segments(**args) + def copy_segments(**args) return if args[:segments].nil? + segments = save_segments(segments: args[:segments], current_user_id: args[:current_user_id]) + segments.each do |segment| + properties = segment.properties + properties['layers'].keys.each do |key| + layer = properties['layers'][key] + field_uploads = layer['fields'].select { |ss| ss['type'] == 'upload' } + field_uploads&.each do |upload| + idx = properties['layers'][key]['fields'].index(upload) + files = upload["value"] && upload["value"]["files"] + files&.each_with_index do |fi, fdx| + aid = properties['layers'][key]['fields'][idx]['value']['files'][fdx]['aid'] + unless aid.nil? + copied_att = Attachment.find(aid)&.copy(attachable_type: 'SegmentProps', attachable_id: segment.id, transferred: true) + unless copied_att.nil? + copied_att.save! + properties['layers'][key]['fields'][idx]['value']['files'][fdx]['aid'] = copied_att.id + properties['layers'][key]['fields'][idx]['value']['files'][fdx]['uid'] = copied_att.identifier + end + end + end + end + end + segment.update!(properties: properties) + end + end + + def save_segments(**args) + return if args[:segments].nil? + segments = [] args[:segments].each do |seg| klass = SegmentKlass.find_by(id: seg['segment_klass_id']) uuid = SecureRandom.uuid @@ -29,7 +58,9 @@ def save_segments(**args) props['klass_uuid'] = klass.uuid props['eln'] = Chemotion::Application.config.version props['klass'] = 'Segment' - Segment.create!(segment_klass_id: seg['segment_klass_id'], element_type: self.class.name, element_id: self.id, properties: props, created_by: args[:current_user_id], uuid: uuid, klass_uuid: klass.uuid) + segment = Segment.create!(segment_klass_id: seg['segment_klass_id'], element_type: self.class.name, element_id: self.id, properties: props, created_by: args[:current_user_id], uuid: uuid, klass_uuid: klass.uuid) + segments.push(segment) end + segments end end diff --git a/app/models/concerns/taggable.rb b/app/models/concerns/taggable.rb index 94fc5abf5..5e01c2647 100644 --- a/app/models/concerns/taggable.rb +++ b/app/models/concerns/taggable.rb @@ -61,12 +61,13 @@ def collection_tag cols = [] send(klass).each do |cc| next unless c = cc.collection - next if c.label == 'All' && c.is_locked + next if (c.label == 'All' && c.is_locked) cols.push({ name: c.label, is_shared: c.is_shared, user_id: c.user_id, id: c.id, shared_by_id: c.shared_by_id, is_synchronized: false }) + next if (c.id == Collection.public_collection_id) || (c.id == Collection.scheme_only_reactions_collection_id) if c.is_synchronized c.sync_collections_users&.each do |syn| cols.push({ @@ -81,8 +82,14 @@ def collection_tag end def grouped_analyses - analyses.map(&:extended_metadata).map { |x| x.extract!('kind', 'status') } - .group_by { |x| x['status'] } + analyses_extended_metadata = analyses.map(&:extended_metadata) + links_extended_metadata = links.map do |container| + target_container = Container.find(container.extended_metadata['target_id']) + target_container[:extended_metadata] + end + extended_metadata = analyses_extended_metadata.concat(links_extended_metadata) + extended_metadata.map { |x| x.extract!('kind', 'status') } + .group_by { |x| x['status'] } end def count_by_kind(analyses) @@ -90,7 +97,7 @@ def count_by_kind(analyses) end def analyses_tag - return nil unless is_a?(Sample) && analyses.count.positive? + return nil unless is_a?(Sample) && (analyses.count.positive? || links.count.positive?) grouped_analyses.map { |key, val| vv = count_by_kind(val) kk = key.to_s.downcase diff --git a/app/models/container.rb b/app/models/container.rb index e300d393a..97003554f 100644 --- a/app/models/container.rb +++ b/app/models/container.rb @@ -25,10 +25,14 @@ class Container < ApplicationRecord belongs_to :containable, polymorphic: true, optional: true has_many :attachments, as: :attachable + has_one :publication, as: :element + include Taggable + + has_one :doi, as: :doiable # TODO: dependent destroy for attachments should be implemented when attachment get paranoidized instead of this DJ before_destroy :delete_attachment before_destroy :destroy_datasetable - has_closure_tree + has_closure_tree order: Arel.sql("extended_metadata->'index' asc") scope :analyses_for_root, ->(root_id) { where(container_type: 'analysis').joins( @@ -36,10 +40,32 @@ class Container < ApplicationRecord ) } + scope :links_for_root, ->(root_id) { + where(container_type: 'link').joins( + "inner join container_hierarchies ch on ch.generations = 2 and ch.ancestor_id = #{root_id} and ch.descendant_id = containers.id " + ) + } + + scope :analyses_container, ->(id) { + where(container_type: 'analyses').joins( + <<~SQL + inner join container_hierarchies ch + on (ch.ancestor_id = #{id} and ch.descendant_id = containers.id) + or (ch.descendant_id = #{id} and ch.ancestor_id = containers.id) + SQL + ) + } + + before_save :check_doi + def analyses Container.analyses_for_root(self.id) end + def links + Container.links_for_root(self.id) + end + def root_element self.root.containable end @@ -50,8 +76,6 @@ def self.create_root_container(**args) root_con end - private - def delete_attachment if Rails.env.production? attachments.each { |attachment| @@ -61,4 +85,41 @@ def delete_attachment attachments.each(&:destroy!) end end + + def generate_doi version + type = self.extended_metadata['kind'].delete(' ') if self.extended_metadata['kind'] + version_str = version.to_i == 0 ? "" : "." + version.to_s + term_id = (type || '').split('|').first.sub!(':','') + if self.root.containable.respond_to? :molecule + ds_version = self.root.containable.molecule.inchikey + "/" + term_id + version_str + elsif self.root.containable.respond_to? :products_short_rinchikey_trimmed + ds_version = "reaction/" + self.root.containable.products_short_rinchikey_trimmed+ "/" + term_id + version_str + else + ds_version = term_id + version_str + end + "#{Datacite::Mds.new.doi_prefix}/#{ds_version}" + end + + def full_doi + return nil unless (d = Doi.find_by(doiable: self)) + d.full_doi + end + + def unassociate_doi(d = self.doi) + d.update(doiable: nil) unless d.nil? + self.extended_metadata.delete('reserved_doi') + at = self.tag + at.taggable_data.delete('reserved_doi') + at.save + end + + def check_doi + # unassoicate doi if type has changed + if (d = self.doi) && self.container_type == 'analysis' && self.publication&.state != 'completed' + if self.extended_metadata['kind']&.delete(' ') != d.analysis_type + unassociate_doi(d) + end + end + true + end end diff --git a/app/models/device.rb b/app/models/device.rb index fe09f938f..301bd21da 100644 --- a/app/models/device.rb +++ b/app/models/device.rb @@ -23,19 +23,18 @@ # name_abbreviation :string(12) # type :string default("Person") # reaction_name_prefix :string(3) default("R") +# layout :hstore not null # confirmation_token :string # confirmed_at :datetime # confirmation_sent_at :datetime # unconfirmed_email :string -# layout :hstore not null # selected_device_id :integer # failed_attempts :integer default(0), not null # unlock_token :string # locked_at :datetime # account_active :boolean # matrix :integer default(0) -# omniauth_provider :string -# omniauth_uid :string +# providers :jsonb # # Indexes # diff --git a/app/models/doi.rb b/app/models/doi.rb new file mode 100644 index 000000000..633df5ae9 --- /dev/null +++ b/app/models/doi.rb @@ -0,0 +1,201 @@ +# == Schema Information +# +# Table name: dois +# +# id :integer not null, primary key +# molecule_id :integer +# inchikey :string +# molecule_count :integer +# analysis_id :integer +# analysis_type :string +# analysis_count :integer +# metadata :jsonb +# minted :boolean default(FALSE) +# minted_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# doiable_id :integer +# doiable_type :string +# suffix :string +# version_count :integer default(0) +# +# Indexes +# +# index_dois_on_suffix (suffix) UNIQUE +# index_on_dois (inchikey,molecule_count,analysis_type,analysis_count,version_count) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (molecule_id => molecules.id) +# + +class Doi < ApplicationRecord + + belongs_to :doiable, polymorphic: true, optional: true + belongs_to :molecule, class_name: 'Molecule', optional: true + belongs_to :analysis, class_name: 'Container', optional: true + + before_save :align_suffix + + def build_suffix + s = "#{inchikey}" + s += ".#{molecule_count}" if molecule_count.to_i > 0 + if analysis_type.present? + term_id = analysis_type.split('|').first.sub!(':','') + s += "/#{term_id}" + s += ".#{analysis_count}" if analysis_count.to_i > 0 + end + s += "/#{version_count}" if version_count.to_i > 0 + s + end + + def generate_doi + "#{Datacite::Mds.new.doi_prefix}/#{suffix}" + end + + def full_doi + "#{Datacite::Mds.new.doi_prefix}/#{suffix}" + end + + def self.find_by_doi(doi) + d = split_doi(doi) + find_by(suffix: d[:suffix]) + end + + def self.find_by_split_suffix(suffix) + split_suffix = split_suffix(suffix) + find_by(split_suffix) + end + + def self.create_for_analysis!(analysis, ik = nil) + if !ik + rt = analysis.root_element + ik = (rt.is_a?(Sample) && rt.molecule.inchikey) || + (rt.is_a?(Reaction) && rt.products_short_rinchikey_trimmed) + raise "only works with sample/reaction analysis" unless ik + end + + type = analysis.extended_metadata['kind'].delete(' ') + type = type.presence || 'nd' + term_id = type.split('|').first.sub!(':','') + + if (previous_version_doi_id = analysis.extended_metadata['previous_version_doi_id']) + previous_doi = Doi.find_by(id: previous_version_doi_id) + ac = previous_doi.analysis_count + ac_string = ".#{ac}" + vc = previous_doi.version_count.to_i + 1 + vc_string = "/#{vc}" + suffix = "#{ik}/#{term_id}#{ac_string}#{vc_string}" + else + ds = Doi.select("*, coalesce(analysis_count, 0) as real_count") + .where(inchikey: ik, analysis_type: type) + .order('real_count desc') + if ds.blank? + ac = 0 + ac_string = '' + else + ac = ds.first.analysis_count.to_i.next + ac_string = ".#{ac}" + end + vc = 0 + suffix = "#{ik}/#{term_id}#{ac_string}" + end + + Doi.create!( + inchikey: ik, + doiable_id: analysis.id, + doiable_type: analysis.class.name, + analysis_count: ac, + version_count: vc, + suffix: suffix, + analysis_type: type + ) + end + + def self.create_for_element!(element, ik = nil) + klass = element.class.name + ik ||= case klass + when 'Sample' + element.molecule.inchikey + when 'Collection' + "collection/" + element.label + when 'Reaction' + "reaction/" + element.products_short_rinchikey_trimmed + end + + if (previous_version = element.tag.taggable_data['previous_version']) + previous_doi = Doi.find_by(id: previous_version['doi']['id']) + mc = previous_doi.molecule_count + mc_string = ".#{mc}" + vc = previous_doi.version_count.to_i + 1 + vc_string = "/#{vc}" + suffix = "#{ik}#{mc_string}#{vc_string}" + else + ds = Doi.select("*, coalesce(molecule_count, 0) as real_count") + .where(inchikey: ik) + .order('real_count desc') + if ds.blank? + mc = 0 + mc_string = '' + else + mc = ds.first.molecule_count.to_i.next + mc_string = ".#{mc}" + end + vc = 0 + suffix = "#{ik}#{mc_string}" + end + + d = Doi.create!( + inchikey: ik, + doiable_id: element.id, + doiable_type: klass, + molecule_count: mc, + version_count: vc, + suffix: suffix + ) + end + + private + + def self.extract_suffix(doi) + if doi.match(/(10\..+?)\//) + $' + end + end + + def self.split_doi(doi) + if doi.match(/(10\..+?)\//) + { prefix: $1, suffix: $', split_suffix: split_suffix($') } + else + {} + end + end + + def self.split_suffix(suffix) + if suffix.match(/([\w-]+)(?:\.(\d+))?(?:\/((?:.+62\.5)|(?:[^.]+))?(?:\.(\d+))?)?\z/) + { + inchikey: $1, + molecule_count: $2, + analysis_type: $3, + analysis_count: $4, + } + else + {} + end + end + + def align_suffix + if suffix_changed? && !inchikey_changed? && !analysis_type_changed? + s = self.class.split_suffix(suffix) + self.inchikey = s[:inchikey] + self.molecule_count = s[:molecule_count] + self.analysis_type = s[:analysis_type] + self.analysis_count = s[:analysis_count] + else !suffix_changed? && inchikey_changed? + self.suffix = build_suffix + end + # throw(:abort) unless suffix == build_suffix + suffix == build_suffix + end + +end diff --git a/app/models/group.rb b/app/models/group.rb index 07ed20677..2d307e4b8 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -23,19 +23,18 @@ # name_abbreviation :string(12) # type :string default("Person") # reaction_name_prefix :string(3) default("R") +# layout :hstore not null # confirmation_token :string # confirmed_at :datetime # confirmation_sent_at :datetime # unconfirmed_email :string -# layout :hstore not null # selected_device_id :integer # failed_attempts :integer default(0), not null # unlock_token :string # locked_at :datetime # account_active :boolean # matrix :integer default(0) -# omniauth_provider :string -# omniauth_uid :string +# providers :jsonb # # Indexes # diff --git a/app/models/literature.rb b/app/models/literature.rb index 1e3af44d1..c2398ca27 100644 --- a/app/models/literature.rb +++ b/app/models/literature.rb @@ -34,7 +34,7 @@ class Literature < ApplicationRecord scope :add_user_info, -> { joins("inner join users on users.id = literals.user_id") - .select("literatures.*, literals.id as literal_id, literals.user_id, literals.litype, (users.first_name || chr(32) || users.last_name) as user_name") + .select("literatures.*, literals.id as literal_id, literals.element_type, literals.user_id, literals.litype, (users.first_name || chr(32) || users.last_name) as user_name") } scope :add_element_and_user_info, -> { diff --git a/app/models/molecule.rb b/app/models/molecule.rb index 42e2a96bc..c910635c4 100644 --- a/app/models/molecule.rb +++ b/app/models/molecule.rb @@ -135,6 +135,8 @@ def assign_molecule_data(babel_info, pubchem_info = {}) self.names = pubchem_info[:names] self.pcid = pubchem_info[:cid] self.check_sum_formular # correct exact and average MW for resins + svg = Molecule.svg_reprocess(babel_info[:svg], molfile) + attach_svg svg #self.attach_svg babel_info[:svg] svg = Chemotion::OpenBabelService.svg_from_molfile(self.molfile) @@ -151,12 +153,12 @@ def pubchem_lcss mol_tag = self.tag mol_tag_data = mol_tag.taggable_data || {} if mol_tag_data['pubchem_lcss'] && mol_tag_data['pubchem_lcss'].length > 0 - mol_tag_data['pubchem_lcss']; + mol_tag_data['pubchem_lcss'] else mol_tag_data['pubchem_lcss'] = Chemotion::PubchemService.lcss_from_cid(cid) # updated_at of element_tags(not molecule) is updated mol_tag.update_attributes taggable_data: mol_tag_data - mol_tag_data['pubchem_lcss']; + mol_tag_data['pubchem_lcss'] end end @@ -234,6 +236,20 @@ def unique_molecule_name(new_name) !mns.include?(new_name) end + def self.svg_reprocess(svg, molfile) + return svg unless Rails.configuration.try(:ketcher_service).try(:url).present? + return svg if svg.present? && !svg&.include?('Open Babel') + + svg = KetcherService::RenderSvg.svg(molfile) + + if svg&.present? + svg = Ketcherails::SVGProcessor.new(svg) + svg.centered_and_scaled_svg + else + Chemotion::OpenBabelService.svg_from_molfile(molfile) + end + end + private # TODO: check that molecules are OK and remove this method. fix is in editor diff --git a/app/models/person.rb b/app/models/person.rb index 84427b6be..ccc2bfc7d 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -23,19 +23,18 @@ # name_abbreviation :string(12) # type :string default("Person") # reaction_name_prefix :string(3) default("R") +# layout :hstore not null # confirmation_token :string # confirmed_at :datetime # confirmation_sent_at :datetime # unconfirmed_email :string -# layout :hstore not null # selected_device_id :integer # failed_attempts :integer default(0), not null # unlock_token :string # locked_at :datetime # account_active :boolean # matrix :integer default(0) -# omniauth_provider :string -# omniauth_uid :string +# providers :jsonb # # Indexes # diff --git a/app/models/publication.rb b/app/models/publication.rb new file mode 100644 index 000000000..0c44cf85a --- /dev/null +++ b/app/models/publication.rb @@ -0,0 +1,740 @@ +# == Schema Information +# +# Table name: publications +# +# id :integer not null, primary key +# state :string +# metadata :jsonb +# taggable_data :jsonb +# dois :jsonb +# element_type :string +# element_id :integer +# doi_id :integer +# created_at :datetime +# updated_at :datetime +# deleted_at :datetime +# original_element_type :string +# original_element_id :integer +# ancestry :string +# metadata_xml :text +# published_by :integer +# published_at :datetime +# review :jsonb +# accepted_at :datetime +# oai_metadata_xml :text +# +# Indexes +# +# index_publications_on_ancestry (ancestry) +# publications_element_idx (element_type,element_id,deleted_at) +# +class Publication < ActiveRecord::Base + class Net::FTP + def puttextcontent(content, remotefile, &block) + f = StringIO.new(content) + begin + storlines('STOR ' + remotefile, f, &block) + ensure + f.close + end + end + end + + acts_as_paranoid + include MetadataJsonld + has_ancestry + belongs_to :element, polymorphic: true + belongs_to :original_element, polymorphic: true, optional: true + belongs_to :doi + + STATE_START = 'start' + + STATE_DC_METADATA_UPLOADING = 'dc_metadata_uploading' + STATE_DC_METADATA_UPLOADED = 'dc_metadata_uploaded' + + # STATE_DC_METADATA_ANALYSES_UPLOADING = 'dc_metadata_analyses_uploading' + # STATE_DC_METADATA_ANALYSES_UPLOADED = 'dc_metadata_analyses_uploaded' + + STATE_DC_DOI_REGISTERING = 'dc_doi_registering' + STATE_DC_DOI_REGISTERED = 'dc_doi_registered' + + STATE_PUBCHEM_REGISTERING = 'pubchem_registering' + STATE_PUBCHEM_REGISTERED = 'pubchem_registered' + + # STATE_DC_DOI_MINT_ANALYSES_START = 'dc_doi_mint_analyses_start' + # STATE_DC_DOI_MINT_ANALYSES_DONE = 'dc_doi_mint_analyses_done' + + STATE_COMPLETING = 'completing' + STATE_COMPLETED = 'completed' + STATE_PUBLISHED = 'published' + + STATE_ACCEPTED = 'accepted' + STATE_PENDING = 'pending' + STATE_DECLINED = 'declined' + STATE_REVIEWED = 'reviewed' + STATE_RETRACTED = 'retracted' + + def embargoed?(root_publication = root) + cid = User.find(root_publication.published_by).publication_embargo_collection.id + embargo_col = root_publication.element.collections.select { |c| c['ancestry'].to_i == cid } + embargo_col.present? ? true : false + end + + # Moving publication element to collections according to publication state + + def update_state(new_state) + update_columns(state: new_state) + descendants.each { |d| d.update_columns(state: new_state) } + update_columns(accepted_at: Time.now.utc) if new_state == Publication::STATE_ACCEPTED + descendants.each { |d| d.update_columns(accepted_at: Time.now.utc) } if new_state == Publication::STATE_ACCEPTED + + process_element(new_state) + end + + def process_element(new_state = state) + case new_state + when Publication::STATE_PENDING + move_to_pending_collection + group_review_collection + when Publication::STATE_REVIEWED + move_to_review_collection + group_review_collection + when Publication::STATE_ACCEPTED + move_to_accepted_collection + group_review_collection + when Publication::STATE_DECLINED + if element&.tag&.taggable_data['previous_version'].nil? + declined_reverse_original_element + declined_move_collections + group_review_collection + end + end + end + + # WARNING: for STATE_ACCEPTED the method does more than just notify users (see ChemotionRepoPublishingJob) + # TODO: separate publishing responsability + def inform_users(new_state = state, current_user_id = 0) + method = if ENV['PUBLISH_MODE'] == 'production' && Rails.env.production? + :perform_later + elsif ENV['PUBLISH_MODE'] == 'staging' + :perform_now + end + return unless method + + queue_name = new_state.to_s + klass = if new_state == Publication::STATE_ACCEPTED && !embargoed? + queue_name = 'publishing' + ChemotionRepoPublishingJob + else + ChemotionRepoReviewingJob + end + klass.set(queue: "#{queue_name} #{id}").send(method, id, new_state, current_user_id) + end + + # remove publication element from editable collections + def move_to_accepted_collection + pub_user = User.find(published_by) + return false unless pub_user && element + return true unless embargoed? + + ## do not move if in reviewing, but why ??? + # reviewing_col = element.collections.where(id: pub_user.reviewing_collection.id).pluck(:id) + # return false if reviewing_col.present? + + case element_type + when 'Sample' + CollectionsSample + when 'Reaction' + CollectionsReaction + end.move_to_collection( + element.id, + element.collections.where( + id: [pub_user.pending_collection.id, pub_user.reviewing_collection.id] + ).pluck(:id) + Collection.element_to_review_collection.pluck(:id), + [Collection.embargo_accepted_collection&.id] + ) + end + + # move publication element from reviewer editable collection to submitter editable collection + # move publication element from submitter readable collection to reviewer readable collection + def move_to_review_collection + pub_user = User.find(published_by) + return false unless pub_user && element + + case element_type + when 'Sample' + CollectionsSample + when 'Reaction' + CollectionsReaction + end.move_to_collection( + element.id, + [pub_user.pending_collection.id] + Collection.element_to_review_collection.pluck(:id), + [pub_user.reviewing_collection.id] + Collection.reviewed_collection.pluck(:id) + ) + end + + def move_to_pending_collection + pub_user = User.find(published_by) + return false unless pub_user && element + + case element_type + when 'Sample' + CollectionsSample + when 'Reaction' + CollectionsReaction + end.move_to_collection( + element.id, + [pub_user.reviewing_collection.id] + Collection.reviewed_collection.pluck(:id), + [pub_user.pending_collection.id] + Collection.element_to_review_collection.pluck(:id) + ) + end + + def declined_reverse_original_element + oet = original_element&.tag + oet&.taggable_data&.delete('publish_pending') + oet&.taggable_data&.delete('public_reaction') if element_type == 'Reaction' + oet&.taggable_data&.delete('public_sample') if element_type == 'Sample' + oet&.save! + original_element&.analyses&.each do |a| + a.reload + a.tag&.taggable_data&.delete('public_analysis') && a.tag.save! + a.extended_metadata&.delete('public_analysis') && a.save! + end + declined_reverse_original_reaction_elements + end + + def group_review_collection + pub_user = User.find(published_by) + return false unless pub_user && element + + group_reviewers = review['reviewers'] + reviewers = User.where(id: group_reviewers) if group_reviewers.present? + return false if reviewers&.empty? + + reviewers&.each do |user| + ## user = User.find(gl_id) + col = user.find_or_create_grouplead_collection + case element_type + when 'Sample' + CollectionsSample + when 'Reaction' + CollectionsReaction + end.create_in_collection([element.id], [col.id]) + end + + end + + def declined_reverse_original_reaction_elements + element_type == 'Reaction' && original_element&.samples&.each do |s| + spp = s.tag&.taggable_data&.delete('publish_pending') + sps = s.tag&.taggable_data&.delete('public_sample') + s.tag&.save! unless spp.nil? && sps.nil? + original_element&.analyses&.each do |a| + a.reload + a.tag&.taggable_data&.delete('public_analysis') && a.tag.save! + a.extended_metadata&.delete('public_analysis') && a.save! + end + end + end + + def declined_move_collections + all_col_id = User.find(published_by).all_collection&.id + return unless element && all_col_id + + col_ids = element&.collections&.pluck(:id) + et = element.tag + et.taggable_data = et.taggable_data.merge(decline: true) + tag_pub = et.taggable_data&.delete('publication') + et.save! + case element_type + when 'Reaction' + CollectionsReaction + when 'Sample' + CollectionsSample + end.move_to_collection(element_id, col_ids, all_col_id) + end + + # NB: ?NOT USED? Was it meant for declining/rejecting publication + # NB: was Moved to model from ChemotionRepoReviewingJob + # TODO: move this to publising concern ? + # def release_original_element + # ot = original_element&.tag&.taggable_data&.delete("publish_#{element_type.downcase}") + # original_element.tag.save! unless ot.nil? + # + # case element_type + # when 'Sample' + # clear_orig_analyses(original_element) + # when 'Reaction' + # clear_orig_analyses(original_element) + # original_element&.samples&.each do |s| + # t = s.tag&.taggable_data&.delete('publish_sample') + # s.tag.save! unless t.nil? + # clear_orig_analyses(s) + # end + # end + # end + + # TODO: mv this to publishing concern ?? + # def clear_orig_analyses(el = element) + # el&.analyses&.each do |a| + # t = a.tag&.taggable_data&.delete('publish_analysis') + # a.tag.save! unless t.nil? + # end + # end + + def publication_logger + @@publication_logger ||= Logger.new(File.join(Rails.root, 'log', 'publication.log')) + end + + def doi_bag + d = doi + case element_type + when 'Collection' + dois = { + collection: { + DOI: d.full_doi, + suffix: d.suffix, + inchikey: d.inchikey, + count: d.molecule_count + }, + element_dois: {} + } + eids = taggable_data["eids"] + eids.each do |eid| + sp = Publication.find(eid) + sd = sp.doi + dois[:element_dois][sp.element_id.to_s] = { + DOI: sd.full_doi, + suffix: sd.suffix, + type: sd.analysis_type, + count: sd.analysis_count + } + end + when 'Sample' + dois = { + sample: { + DOI: d.full_doi, + suffix: d.suffix, + inchikey: d.inchikey, + count: d.molecule_count + }, + analyses_dois: {} + } + children.each do |a| + d = a.doi + dois[:analyses_dois][a.element_id.to_s] = { + DOI: d.full_doi, + suffix: d.suffix, + type: d.analysis_type, + count: d.analysis_count + } + end + when 'Reaction' + dois = { + reaction: { + DOI: d.full_doi, + suffix: d.suffix, + inchikey: d.inchikey, + count: d.molecule_count + }, + analyses_dois: {} + } + children.where(element_type: 'Container').each do |a| + d = a.doi + dois[:analyses_dois][a.element_id.to_s] = { + DOI: d.full_doi, + suffix: d.suffix, + type: d.analysis_type, + count: d.analysis_count + } + end + dois['samples'] = {} + children.where(element_type: 'Sample').each do |new_sample| + d = new_sample.doi + if d + dois['samples'][new_sample.element_id.to_s] = { + sample: { + DOI: d.full_doi, + suffix: d.suffix, + inchikey: d.inchikey, + count: d.molecule_count + }, + analyses_dois: {} + } + end + end + when 'Container' + parent_doi = parent.doi + dois = { + analysis: { + DOI: d.full_doi, + suffix: d.suffix, + inchikey: d.inchikey, + count: d.molecule_count + }, + parent_element: { + DOI: parent_doi.full_doi, + suffix: parent_doi.suffix, + inchikey: parent_doi.inchikey, + count: parent_doi.molecule_count + } + } + end + return dois + end + + def scheme_only + taggable_data && taggable_data['scheme_only'] == true ? true : false + end + + def rights_data + rights = { + schemeURI: 'https://spdx.org/licenses/', + rightsIdentifierScheme: 'SPDX', + rightsIdentifier: 'CC-BY-SA-4.0', + rightsURI: 'http://creativecommons.org/licenses/by-sa/4.0/', + rightsName: 'Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)' + } + if taggable_data && taggable_data['license'].present? + case taggable_data['license'] + when 'CC BY' + rights[:rightsIdentifier] = 'CC-BY-4.0' + rights[:rightsURI] = 'http://creativecommons.org/licenses/by/4.0/' + rights[:rightsName] = 'Attribution 4.0 International (CC BY 4.0)' + when 'CC0' + rights[:rightsIdentifier] = 'CC0-1.0' + rights[:rightsURI] = 'http://creativecommons.org/publicdomain/zero/1.0/' + rights[:rightsName] = 'CC0 1.0 Universal' + when 'No License' + rights[:rightsIdentifier] = '' + rights[:rightsURI] = '' + rights[:rightsName] = '' + rights[:schemeURI] = '' + rights[:rightsIdentifierScheme] = '' + end + end + rights + end + + def datacite_metadata_xml + previous_version = element.tag.taggable_data['previous_version'] + unless previous_version.nil? + previous_version_tag = ElementTag.find_by(taggable_type: element_type, + taggable_id: previous_version['id']) + previous_version_doi = previous_version_tag.taggable_data.fetch('publication', {}).fetch('doi') + end + + if parent.nil? && %w[Sample Reaction].include?(element_type) + coly = element.collections.where( + <<~SQL + collections.id in (select element_id from publications pub where element_type = 'Collection' and element_id = collections.id) + SQL + ).last + cdoi = coly.publication&.doi&.full_doi if coly.present? + end + parent_element = parent&.element + literals = ActiveRecord::Base.connection.exec_query(literals_sql(element_id, element_type)) + metadata_obj = OpenStruct.new(pub: self, element: element, pub_tag: taggable_data, dois: doi_bag, + parent_element: parent_element.presence, previous_version_doi: previous_version_doi, + rights: rights_data, lits: literals, col_doi: cdoi, cust_sample: cust_sample) + erb_file = if element_type == 'Container' + "app/publish/datacite_metadata_#{parent_element.class.name.downcase}_#{element_type.downcase}.html.erb" + else + "app/publish/datacite_metadata_#{element_type.downcase}.html.erb" + end + metadata_file = ERB.new(File.read(File.join( + Rails.root, + erb_file + ))) + metadata_file.result(metadata_obj.instance_eval { binding }) + end + + def persit_datacite_metadata_xml! + mt = datacite_metadata_xml + self.update!(metadata_xml: mt, oai_metadata_xml: mt) + mt + end + + def persit_oai_metadata_xml! + mt = datacite_metadata_xml + self.update_columns(oai_metadata_xml: mt) + mt + end + + def transition_from_start_to_metadata_uploading! + return unless valid_transition(STATE_DC_METADATA_UPLOADING) + mt = datacite_metadata_xml + self.update!(metadata_xml: mt, oai_metadata_xml: mt, state: STATE_DC_METADATA_UPLOADING) + end + + def transition_from_metadata_uploading_to_uploaded! + return unless valid_transition(STATE_DC_METADATA_UPLOADED) + if (ENV['DATACITE_MODE'] == 'test' || ENV['PUBLISH_MODE'] == 'production') && scheme_only == false + resp = Datacite::Mds.new.upload_metadata(metadata_xml) + success = resp.is_a?(Net::HTTPSuccess) + message = "#{resp.inspect}: metadata upload#{"ing fail" if !success}ed" + else + success = true + message = "metadata not uploaded in mode #{ENV['PUBLISH_MODE']}" + end + logger([message, metadata]) + raise "#{message}" unless success + self.update!(state: STATE_DC_METADATA_UPLOADED) + end + + def transition_from_metadata_uploaded_to_doi_registering! + return unless valid_transition(STATE_DC_DOI_REGISTERING) + self.update!(state: STATE_DC_DOI_REGISTERING) + end + + def transition_from_doi_registering_to_registered! + return unless valid_transition(STATE_DC_DOI_REGISTERED) + mds = Datacite::Mds.new + suffix = doi.suffix + short_doi = "#{mds.doi_prefix}/#{suffix}" + url = "https://#{mds.doi_domain}/inchikey/#{suffix}" + if (ENV['DATACITE_MODE'] == 'test' || ENV['PUBLISH_MODE'] == 'production') && scheme_only == false + resp = mds.mint(short_doi, url) + success = resp.is_a?(Net::HTTPSuccess) + message = "#{resp.inspect}: DOI mint#{'ing fail' unless success}ed" + else + success = true + message = "DOI not minted in mode #{ENV['PUBLISH_MODE']}" + end + logger([message, "doi: #{short_doi}", "url: #{url}"]) + raise "#{message}:\n #{short_doi} <-> #{url}" unless success + doi_date = DateTime.now + case element_type + when 'Sample' + chem_first = element.pubchem_cid.blank? + tag_data = { + doi_reg_at: doi_date, + sample_version: doi.molecule_count, + doi: short_doi + } + tag_data[:chem_first] = doi_date if chem_first + element.update_publication_tag(tag_data) + when 'Container' + et = ElementTag.find_or_create_by( + taggable_type: 'Container', taggable_id: element_id + ) + tag_data = parent.element.publication_tag.merge({ + published_at: doi_date, + dataset_version: doi.analysis_count, + analysis_doi: short_doi + }) + et.update!( + taggable_data: (et.taggable_data || {}).merge(publication: tag_data) + ) + when 'Reaction' + tag_data = { + doi_reg_at: doi_date, + reaction_version: doi.molecule_count, + doi: short_doi + } + element.update_publication_tag(tag_data) + when 'Collection' + tag_data = { + doi_reg_at: doi_date, + doi: short_doi + } + end + + pd = taggable_data.merge(tag_data) + self.update!(state: STATE_DC_DOI_REGISTERED,taggable_data: taggable_data.merge(pd)) + end + + def transition_from_doi_registered_to_pubchem_registering! + return unless valid_transition(STATE_PUBCHEM_REGISTERING) + self.update!(state: STATE_PUBCHEM_REGISTERING) + end + + def transition_from_doi_pubchem_registering_to_registered! + return unless valid_transition(STATE_PUBCHEM_REGISTERED) + if element_type == 'Sample' + metadata_obj = OpenStruct.new(sample: element) + metadata_file = ERB.new(File.read( + File.join(Rails.root,'app', 'publish', 'pubchem_metadata.sdf.erb') + )) + mt = metadata_file.result(metadata_obj.instance_eval { binding }) + if (production = Rails.env.production? && ENV['PUBLISH_MODE'] == 'production') && scheme_only == false + ftp = Net::FTP.new('ftp-private.ncbi.nlm.nih.gov') + ftp.passive = true + ftp.login(ENV['PUBCHEM_LOGIN'], ENV['PUBCHEM_PASSWORD']) + ftp.puttextcontent(mt, doi.suffix + '.sdf.in') + ftp.close + end + message = "\n---MOLFILE START---" + element.update_publication_tag(pubchem_reg_at: DateTime.now) + pd = taggable_data.merge(pubchem_reg_at: DateTime.now) + logger([message, mt, "Pubchem FTP upload #{production ? '' : 'NOT'} sent (mode: #{ENV['PUBLISH_MODE']})"]) + end + self.update!(state: STATE_PUBCHEM_REGISTERED, taggable_data: taggable_data.merge(pd)) + end + + def transition_from_pubchem_registered_to_completing! + return unless valid_transition(STATE_COMPLETING) + self.update!(state: STATE_COMPLETING) + end + + def transition_from_doi_registered_to_completing! + return unless valid_transition(STATE_COMPLETING) + self.update!(state: STATE_COMPLETING) + end + + def transition_from_completing_to_completed! + return unless valid_transition(STATE_COMPLETED) + pd = {} + time = DateTime.now + if element_type != 'Container' && element_type != 'Collection' + creator_ids = taggable_data['author_ids'] + su_id = User.chemotion_user.id + + pending_collection_ids = Collection.joins( + "INNER JOIN sync_collections_users ON sync_collections_users.collection_id = collections.id" + ).where("sync_collections_users.shared_by_id = ?", su_id) + .where("sync_collections_users.user_id in (?)", published_by) + .where("collections.label = 'Pending Publications'").pluck(:id) + + my_published_collection_ids = Collection.joins( + "INNER JOIN sync_collections_users ON sync_collections_users.collection_id = collections.id" + ).where("sync_collections_users.shared_by_id = ?", User.chemotion_user.id) + .where("sync_collections_users.user_id in (?)", [published_by] + creator_ids) + .where("collections.label = 'Published Elements'") + .pluck(:id) + + if element_type == 'Reaction' && scheme_only + published_collection_ids = my_published_collection_ids + [Collection.scheme_only_reactions_collection.id] + else + published_collection_ids = my_published_collection_ids + [Collection.public_collection_id] + end + collections_klass = "Collections#{element_type}".constantize + if element_type == 'Sample' + gl_col_ids = collections_klass.joins(:collection).where(sample_id: element_id).where("collections.label = 'Group Lead Review'").pluck :collection_id + end + if element_type == 'Reaction' + gl_col_ids = collections_klass.joins(:collection).where(reaction_id: element_id).where("collections.label = 'Group Lead Review'").pluck :collection_id + end + collections_klass.remove_in_collection([element_id], gl_col_ids ) if gl_col_ids.present? + collections_klass.remove_in_collection([element_id], pending_collection_ids) + collections_klass.remove_in_collection([element_id], Collection.element_to_review_collection.pluck(:id)) + collections_klass.remove_in_collection([element_id], [Collection.embargo_accepted_collection&.id]) + collections_klass.create_in_collection([element_id], published_collection_ids) + + element.update_publication_tag(published_at: time) + pd = taggable_data.merge(published_at: time) + logger(['moved to collections']) + end + self.update!(state: STATE_COMPLETED, taggable_data: taggable_data.merge(pd), published_at: time) + end + + def default_line + @default_line ||= "#{element_type} #{element_id}: " + end + + def valid_transition(to_state) + case to_state + when STATE_START + valid = [STATE_START].include?(state) + + when STATE_ACCEPTED + valid = [STATE_ACCEPTED].include?(state) + + when STATE_DC_METADATA_UPLOADING + valid = [STATE_ACCEPTED].include?(state) + + when STATE_DC_METADATA_UPLOADED + valid = [STATE_DC_METADATA_UPLOADING].include?(state) + + when STATE_DC_DOI_REGISTERING + valid = [STATE_DC_METADATA_UPLOADED].include?(state) + + when STATE_DC_DOI_REGISTERED + valid = [STATE_DC_DOI_REGISTERING].include?(state) + + when STATE_PUBCHEM_REGISTERING + valid = (element_type == 'Sample') && [STATE_DC_DOI_REGISTERED].include?(state) + + when STATE_PUBCHEM_REGISTERED + valid = [STATE_PUBCHEM_REGISTERING].include?(state) + + when STATE_COMPLETING + valid = if element_type == 'Sample' + [STATE_PUBCHEM_REGISTERED].include?(state) + else + [STATE_DC_DOI_REGISTERED, STATE_COMPLETING].include?(state) + end + + when STATE_COMPLETED + valid = [STATE_COMPLETING].include?(state) + end + + log_invalid_transition(to_state) unless valid + valid + end + + def literals_sql(e_id, e_type) + <<~SQL + select l.*, + case l.litype + when 'citedOwn' then 'IsCitedBy' + when 'citedRef' then 'Continues' + when 'referTo' then 'References' + end relationtype, + case + when nullif(l2.doi,'') is not null then 'DOI' || ' {|} ' || 'https://dx.doi.org/' || doi + when nullif(l2.isbn,'') is not null then 'ISBN' || ' {|} ' || isbn + when nullif(l2.url,'') is not null then 'URL' || ' {|} ' || url + end relatedIdentifiertype, + l2.title, l2.url, l2.refs, l2.doi, l2.isbn + from literals l + join literatures l2 on l.literature_id = l2.id + where l.element_id = #{e_id} and l.element_type = '#{e_type}' and l.litype in ('citedOwn', 'citedRef', 'referTo') + SQL + end + + def get_xvial(xvial) + com_config = Rails.configuration.compound_opendata + return nil unless com_config.present? && xvial.present? + + CompoundOpenData.where("x_data ->> 'xid' = ?", xvial) + end + + def cust_sample + if element_type == 'Sample' + desc_type = "#{element.decoupled ? 'de' : ''}coupled_sample#{element.molecule_inchikey == 'DUMMY' ? '' : '_structure'}" + melting_point = range_to_s(element.melting_point) + boiling_point = range_to_s(element.boiling_point) + + { type: desc_type, melting_point: melting_point, boiling_point: boiling_point, x_id: get_xvial(element.tag&.taggable_data&.dig('xvial','num'))&.first&.x_short_label } + else + {} + end + end + + def range_to_s(val) + if val.begin == -Float::INFINITY && val.end == Float::INFINITY + '' + else + start = val.begin == -Float::INFINITY ? '' : val.begin.to_f + finish = val.end == Float::INFINITY ? '' : val.end.to_f + "#{start}#{start == '' || finish == '' ? '' : ' - '}#{finish} (°C)" + end + end + + def log_invalid_transition(to_state) + logger("CANNOT TRANSITION from #{state} to #{to_state}") + end + + def logger(message_arr) + message = [message_arr].flatten.join("\n") + publication_logger.info( + <<~INFO + ******** MODE #{ENV['PUBLISH_MODE']}******** + Publication #{id}. STATE: #{state}: #{default_line} + #{message} + ******************************************************************************** + INFO + ) + end +end diff --git a/app/models/publication_authors.rb b/app/models/publication_authors.rb new file mode 100644 index 000000000..e7cdd72b8 --- /dev/null +++ b/app/models/publication_authors.rb @@ -0,0 +1,14 @@ +# == Schema Information +# +# Table name: publication_authors +# +# author_id :text +# element_id :integer +# element_type :string +# state :string +# doi_id :integer +# ancestry :string +# + +class PublicationAuthors < ApplicationRecord +end diff --git a/app/models/publication_collections.rb b/app/models/publication_collections.rb new file mode 100644 index 000000000..11a87c7a6 --- /dev/null +++ b/app/models/publication_collections.rb @@ -0,0 +1,16 @@ +# == Schema Information +# +# Table name: publication_collections +# +# id :integer +# state :string +# element_id :integer +# label :text +# doi :text +# elobj :jsonb +# doi_id :integer +# published_by :integer +# + +class PublicationCollections < ApplicationRecord +end diff --git a/app/models/publication_ontologies.rb b/app/models/publication_ontologies.rb new file mode 100644 index 000000000..d37396c64 --- /dev/null +++ b/app/models/publication_ontologies.rb @@ -0,0 +1,15 @@ +# == Schema Information +# +# Table name: publication_ontologies +# +# element_type :string +# element_id :integer +# container_id :integer +# published_at :datetime +# ontologies :text +# term_id :text +# label :text +# + +class PublicationOntologies < ApplicationRecord +end diff --git a/app/models/reaction.rb b/app/models/reaction.rb index 2cefabe77..37cd5bcac 100644 --- a/app/models/reaction.rb +++ b/app/models/reaction.rb @@ -50,6 +50,7 @@ class Reaction < ApplicationRecord include Taggable include ReactionRinchi include Segmentable + include Publishing serialize :description, Hash serialize :observation, Hash @@ -57,6 +58,7 @@ class Reaction < ApplicationRecord multisearchable against: %i[name short_label rinchi_string] attr_accessor :can_copy + attr_accessor :previous_version # search scopes for exact matching pg_search_scope :search_by_reaction_name, against: :name @@ -136,6 +138,7 @@ class Reaction < ApplicationRecord has_many :sync_collections_users, through: :collections has_many :private_notes, as: :noteable, dependent: :destroy + has_one :doi, as: :doiable belongs_to :creator, foreign_key: :created_by, class_name: 'User' validates :creator, presence: true @@ -143,10 +146,13 @@ class Reaction < ApplicationRecord before_save :update_svg_file! before_save :cleanup_array_fields before_save :auto_format_temperature! + before_save :check_doi before_create :auto_set_short_label after_create :update_counter + before_destroy :remove_from_previous_version + has_one :container, :as => :containable def self.get_associated_samples(reaction_ids) @@ -157,6 +163,10 @@ def analyses self.container ? self.container.analyses : [] end + def links + self.container ? self.container.links : [] + end + def auto_format_temperature! valueUnitCheck = (temperature['valueUnit'] =~ /^(°C|°F|K)$/).present? temperature['valueUnit'] = '°C' if (!valueUnitCheck) @@ -215,7 +225,7 @@ def update_svg_file! paths[prop] = collection.map do |reactions_sample| sample = reactions_sample.sample params = [ - svg_path(sample.sample_svg_file, sample.molecule.molecule_svg_file) + svg_path(sample&.sample_svg_file, sample&.molecule&.molecule_svg_file) ] params[0] = sample.svg_text_path if reactions_sample.show_label params.append(yield_amount(sample.id)) if prop == :products @@ -245,7 +255,8 @@ def svg_path(sample_svg, molecule_svg) end def yield_amount(sample_id) - ReactionsProductSample.find_by(reaction_id: self.id, sample_id: sample_id).try(:equivalent) + rps = ReactionsProductSample.find_by(reaction_id: self.id, sample_id: sample_id) + rps.scheme_yield || rps.equivalent end def solvents_in_svg @@ -259,12 +270,24 @@ def cleanup_array_fields end def auto_set_short_label - prefix = creator.reaction_name_prefix - counter = creator.counters['reactions'].succ - self.short_label = "#{creator.initials}-#{prefix}#{counter}" + unless self.previous_version.present? + prefix = creator.reaction_name_prefix + counter = creator.counters['reactions'].succ + self.short_label = "#{creator.initials}-#{prefix}#{counter}" + else + self.short_label = get_new_version_short_label + end end def update_counter self.creator.increment_counter 'reactions' end + + def remove_from_previous_version + previous_version = self.tag&.taggable_data['previous_version'] + if previous_version + previous_element = Reaction.find_by(id: previous_version['id']) + previous_element.untag_as_previous_version + end + end end diff --git a/app/models/reactions_product_sample.rb b/app/models/reactions_product_sample.rb index 50d1beebf..f74dbcce2 100644 --- a/app/models/reactions_product_sample.rb +++ b/app/models/reactions_product_sample.rb @@ -2,17 +2,18 @@ # # Table name: reactions_samples # -# id :integer not null, primary key -# reaction_id :integer -# sample_id :integer -# reference :boolean -# equivalent :float -# position :integer -# type :string -# deleted_at :datetime -# waste :boolean default(FALSE) -# coefficient :float default(1.0) -# show_label :boolean default(FALSE), not null +# id :integer not null, primary key +# reaction_id :integer +# sample_id :integer +# reference :boolean +# equivalent :float +# position :integer +# type :string +# deleted_at :datetime +# waste :boolean default(FALSE) +# coefficient :float default(1.0) +# scheme_yield :float +# show_label :boolean default(FALSE), not null # # Indexes # diff --git a/app/models/reactions_purification_solvent_sample.rb b/app/models/reactions_purification_solvent_sample.rb index aa2c1c9fa..dcca1d83a 100644 --- a/app/models/reactions_purification_solvent_sample.rb +++ b/app/models/reactions_purification_solvent_sample.rb @@ -2,17 +2,18 @@ # # Table name: reactions_samples # -# id :integer not null, primary key -# reaction_id :integer -# sample_id :integer -# reference :boolean -# equivalent :float -# position :integer -# type :string -# deleted_at :datetime -# waste :boolean default(FALSE) -# coefficient :float default(1.0) -# show_label :boolean default(FALSE), not null +# id :integer not null, primary key +# reaction_id :integer +# sample_id :integer +# reference :boolean +# equivalent :float +# position :integer +# type :string +# deleted_at :datetime +# waste :boolean default(FALSE) +# coefficient :float default(1.0) +# scheme_yield :float +# show_label :boolean default(FALSE), not null # # Indexes # diff --git a/app/models/reactions_reactant_sample.rb b/app/models/reactions_reactant_sample.rb index 958da1b44..a0f0b2d89 100644 --- a/app/models/reactions_reactant_sample.rb +++ b/app/models/reactions_reactant_sample.rb @@ -2,17 +2,18 @@ # # Table name: reactions_samples # -# id :integer not null, primary key -# reaction_id :integer -# sample_id :integer -# reference :boolean -# equivalent :float -# position :integer -# type :string -# deleted_at :datetime -# waste :boolean default(FALSE) -# coefficient :float default(1.0) -# show_label :boolean default(FALSE), not null +# id :integer not null, primary key +# reaction_id :integer +# sample_id :integer +# reference :boolean +# equivalent :float +# position :integer +# type :string +# deleted_at :datetime +# waste :boolean default(FALSE) +# coefficient :float default(1.0) +# scheme_yield :float +# show_label :boolean default(FALSE), not null # # Indexes # diff --git a/app/models/reactions_sample.rb b/app/models/reactions_sample.rb index c3c3e64b0..c41e40a76 100644 --- a/app/models/reactions_sample.rb +++ b/app/models/reactions_sample.rb @@ -2,17 +2,18 @@ # # Table name: reactions_samples # -# id :integer not null, primary key -# reaction_id :integer -# sample_id :integer -# reference :boolean -# equivalent :float -# position :integer -# type :string -# deleted_at :datetime -# waste :boolean default(FALSE) -# coefficient :float default(1.0) -# show_label :boolean default(FALSE), not null +# id :integer not null, primary key +# reaction_id :integer +# sample_id :integer +# reference :boolean +# equivalent :float +# position :integer +# type :string +# deleted_at :datetime +# waste :boolean default(FALSE) +# coefficient :float default(1.0) +# scheme_yield :float +# show_label :boolean default(FALSE), not null # # Indexes # diff --git a/app/models/reactions_solvent_sample.rb b/app/models/reactions_solvent_sample.rb index 947359f81..c180548f1 100644 --- a/app/models/reactions_solvent_sample.rb +++ b/app/models/reactions_solvent_sample.rb @@ -2,17 +2,18 @@ # # Table name: reactions_samples # -# id :integer not null, primary key -# reaction_id :integer -# sample_id :integer -# reference :boolean -# equivalent :float -# position :integer -# type :string -# deleted_at :datetime -# waste :boolean default(FALSE) -# coefficient :float default(1.0) -# show_label :boolean default(FALSE), not null +# id :integer not null, primary key +# reaction_id :integer +# sample_id :integer +# reference :boolean +# equivalent :float +# position :integer +# type :string +# deleted_at :datetime +# waste :boolean default(FALSE) +# coefficient :float default(1.0) +# scheme_yield :float +# show_label :boolean default(FALSE), not null # # Indexes # diff --git a/app/models/reactions_starting_material_sample.rb b/app/models/reactions_starting_material_sample.rb index cc73df342..7e12a1956 100644 --- a/app/models/reactions_starting_material_sample.rb +++ b/app/models/reactions_starting_material_sample.rb @@ -2,17 +2,18 @@ # # Table name: reactions_samples # -# id :integer not null, primary key -# reaction_id :integer -# sample_id :integer -# reference :boolean -# equivalent :float -# position :integer -# type :string -# deleted_at :datetime -# waste :boolean default(FALSE) -# coefficient :float default(1.0) -# show_label :boolean default(FALSE), not null +# id :integer not null, primary key +# reaction_id :integer +# sample_id :integer +# reference :boolean +# equivalent :float +# position :integer +# type :string +# deleted_at :datetime +# waste :boolean default(FALSE) +# coefficient :float default(1.0) +# scheme_yield :float +# show_label :boolean default(FALSE), not null # # Indexes # diff --git a/app/models/report.rb b/app/models/report.rb index 610ebe7b7..32753ec65 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -20,7 +20,7 @@ # updated_at :datetime not null # template :string default("standard") # mol_serials :text default([]) -# si_reaction_settings :text default({"Name"=>true, "CAS"=>true, "Formula"=>true, "Smiles"=>true, "InCHI"=>true, "Molecular Mass"=>true, "Exact Mass"=>true, "EA"=>true}) +# si_reaction_settings :text default({:Name=>true, :CAS=>true, :Formula=>true, :Smiles=>true, :InCHI=>true, :"Molecular Mass"=>true, :"Exact Mass"=>true, :EA=>true}) # prd_atts :text default([]) # report_templates_id :integer # @@ -92,6 +92,10 @@ def create_docx Reporter::WorkerRxnList.new( report: self, template_path: tpl_path, ext: 'html' ).process + when 'doi_list_xlsx' + Reporter::WorkerDoiList.new( + report: self, ext: 'xlsx' + ).process else Reporter::Worker.new( report: self, template_path: tpl_path diff --git a/app/models/sample.rb b/app/models/sample.rb index ca0810d1a..bee4ee311 100644 --- a/app/models/sample.rb +++ b/app/models/sample.rb @@ -12,7 +12,6 @@ # molecule_id :integer # molfile :binary # purity :float default(1.0) -# deprecated_solvent :string default("") # impurities :string default("") # location :string default("") # is_top_secret :boolean default(FALSE) @@ -53,6 +52,11 @@ # class Sample < ApplicationRecord + + require 'net/ftp' + require 'erb' + require 'ostruct' + acts_as_paranoid include ElementUIStateScopes include PgSearch @@ -62,6 +66,7 @@ class Sample < ApplicationRecord include UnitConvertable include Taggable include Segmentable + include Publishing STEREO_ABS = ['any', 'rac', 'meso', 'delta', 'lambda', '(S)', '(R)', '(Sp)', '(Rp)', '(Sa)', '(Ra)'].freeze STEREO_REL = ['any', 'syn', 'anti', 'p-geminal', 'p-ortho', 'p-meta', 'p-para', 'cis', 'trans', 'fac', 'mer'].freeze @@ -72,6 +77,8 @@ class Sample < ApplicationRecord :molecule_iupac_name, :molecule_inchistring, :molecule_inchikey, :molecule_cano_smiles ] + attr_accessor :previous_version + # search scopes for exact matching pg_search_scope :search_by_sum_formula, against: :sum_formula, associated_against: { molecule: :sum_formular @@ -160,12 +167,15 @@ class Sample < ApplicationRecord before_save :attach_svg, :init_elemental_compositions, :set_loading_from_ea before_save :auto_set_short_label + before_save :check_doi before_create :check_molecule_name before_create :set_boiling_melting_points after_save :update_counter after_create :create_root_container after_save :update_data_for_reactions + before_destroy :remove_from_previous_version + has_many :collections_samples, inverse_of: :sample, dependent: :destroy has_many :collections, through: :collections_samples @@ -181,7 +191,7 @@ class Sample < ApplicationRecord has_many :reactions_as_reactant, through: :reactions_reactant_samples, source: :reaction has_many :reactions_as_solvent, through: :reactions_solvent_samples, source: :reaction has_many :reactions_as_product, through: :reactions_product_samples, source: :reaction - + has_many :literals, as: :element, dependent: :destroy has_many :literatures, through: :literals @@ -208,6 +218,8 @@ class Sample < ApplicationRecord belongs_to :creator, foreign_key: :created_by, class_name: 'User' belongs_to :molecule, optional: true + has_one :doi, as: :doiable + accepts_nested_attributes_for :molecule_name accepts_nested_attributes_for :collections_samples accepts_nested_attributes_for :molecule, update_only: true @@ -221,6 +233,14 @@ class Sample < ApplicationRecord delegate :computed_props, to: :molecule, prefix: true delegate :inchikey, to: :molecule, prefix: true, allow_nil: true + def molfile_pubchem + version = Chemotion::OpenBabelService.molfile_version(self.molfile) + mf = Chemotion::OpenBabelService.mofile_clear_coord_bonds(self.molfile, version) + mf = molfile unless mf + mf&.split(/^\$\$\$\$/).first + end + + def molecule_sum_formular (decoupled? ? sum_formula : molecule&.sum_formular) || '' end @@ -245,6 +265,22 @@ def analyses self.container ? self.container.analyses : Container.none end + def links + self.container ? self.container.links : Container.none + end + + #TODO move to molecule (chemotion_ELN) + def pubchem_cid + mol = self.molecule + cid = mol.tag && mol.tag.taggable_data && mol.tag.taggable_data['pubchem_cid'] + if cid + cid + else + mol.update_tag!(pubchem_tag: true) + mol.tag.taggable_data['pubchem_cid'] + end + end + def self.associated_by_user_id_and_reaction_ids(user_id, reaction_ids) (for_user(user_id).by_reaction_ids(reaction_ids)).distinct end @@ -341,24 +377,45 @@ def loading self.residues[0] && self.residues[0].custom_info['loading'].to_f end + def full_svg_path(svg_file_name = sample_svg_file) + Rails.public_path.join('images', 'samples', svg_file_name) + end + def attach_svg svg = self.sample_svg_file return unless svg.present? svg_file_name = "#{SecureRandom.hex(64)}.svg" - if svg =~ /TMPFILE[0-9a-f]{64}.svg/ - svg_path = Rails.public_path.join('images', 'samples', svg.to_s).to_s - FileUtils.mv(svg_path, svg_path.gsub(/(TMPFILE\S+)/, svg_file_name)) - self.sample_svg_file = svg_file_name - elsif svg.start_with?(' 1, 'reaction' => 2, @@ -271,6 +299,130 @@ def all_sync_in_collections_users SyncCollectionsUser.where('user_id IN (?) ', [id] + group_ids) end + def self.is_public + self.find_by(email: ENV['SYS_EMAIL']) + end + + # TODO: mv to initializers + def self.reviewer_ids + (ENV['REVIEWERS'] || '').split(',').map(&:to_i) + end + + def is_reviewer + (ENV['REVIEWERS'] || "").split(",").include?(self.id.to_s) + end + + def is_article_editor + (ENV['NEWSROOM_EDITOR'] || "").split(",").include?(self.id.to_s) + end + + def is_howto_editor + (ENV['HOWTO_EDITOR'] || "").split(",").include?(self.id.to_s) + end + + def pending_collection + su_id = User.chemotion_user.id + Collection.joins( + "INNER JOIN sync_collections_users ON " + + "sync_collections_users.collection_id = collections.id") + .where("sync_collections_users.shared_by_id = #{su_id}") + .where("sync_collections_users.user_id = #{self.id}") + .where("collections.label = 'Pending Publications'").first + end + + def versions_collection + su_id = User.chemotion_user.id + Collection.joins( + "INNER JOIN sync_collections_users ON " + + "sync_collections_users.collection_id = collections.id") + .where("sync_collections_users.shared_by_id = #{su_id}") + .where("sync_collections_users.user_id = #{self.id}") + .where("collections.label = 'New Versions'").first + end + + def reviewing_collection + su_id = User.chemotion_user.id + Collection.joins( + "INNER JOIN sync_collections_users ON " + + "sync_collections_users.collection_id = collections.id") + .where("sync_collections_users.shared_by_id = #{su_id}") + .where("sync_collections_users.user_id = #{self.id}") + .where("collections.label = 'Reviewing'").first + end + + def sync_reviewing_collection + su_id = User.chemotion_user.id + SyncCollectionsUser.joins( + "INNER JOIN collections ON " + + "sync_collections_users.collection_id = collections.id") + .where("sync_collections_users.shared_by_id = #{su_id}") + .where("sync_collections_users.user_id = #{self.id}") + .where("collections.label = 'Reviewing'").first + end + + + def sync_element_to_review_collection + su_id = User.chemotion_user.id + SyncCollectionsUser.joins( + "INNER JOIN collections ON " + + "sync_collections_users.collection_id = collections.id") + .where("sync_collections_users.shared_by_id = #{su_id}") + .where("sync_collections_users.user_id = #{self.id}") + .where("collections.label = 'Element To Review'").first + end + + def find_or_create_grouplead_collection + chemotion_user = User.chemotion_user + sys_review_from = Collection.find_or_create_by(user_id: chemotion_user.id, label: 'Group Lead Review from', is_locked: true, is_shared: false) + sys_review_collection = Collection.find_or_create_by(user: chemotion_user, label: 'Group Lead Review', ancestry: "#{sys_review_from.id}", shared_by_id: id) + + col_attributes = { + user: self, + shared_by_id: chemotion_user.id, + is_locked: true, + is_shared: true + } + + rc = Collection.find_by(col_attributes) + unless rc.nil? + SyncCollectionsUser.find_or_create_by(user: self, shared_by_id: chemotion_user.id, collection_id: sys_review_collection.id, + permission_level: 3, sample_detail_level: 10, reaction_detail_level: 10, fake_ancestry: rc.id.to_s) + end + sys_review_collection + end + + def published_collection + su_id = User.chemotion_user.id + Collection.joins("INNER JOIN sync_collections_users ON sync_collections_users.collection_id = collections.id") + .where("sync_collections_users.shared_by_id = #{su_id}") + .where("sync_collections_users.user_id = #{self.id}") + .where("collections.label = 'Published Elements'").first + end + + def sync_published_collection + SyncCollectionsUser.joins("INNER JOIN collections on collections.id = sync_collections_users.collection_id") + .where("sync_collections_users.user_id = #{self.id}") + .where("collections.id = #{Collection.public_collection_id}").first + + end + + def publication_embargo_collection + su_id = User.chemotion_user.id + Collection.joins("INNER JOIN sync_collections_users ON sync_collections_users.collection_id = collections.id") + .where("sync_collections_users.shared_by_id = #{su_id}") + .where("sync_collections_users.user_id = #{self.id}") + .where("collections.label = 'Embargoed Publications'").first + end + + def all_collection + Collection.where(user: self, label: 'All', is_locked: true, position: 0)&.first + + end + + def self.chemotion_user + find_by(email: ENV['SYS_EMAIL']) + end + def current_affiliations Affiliation.joins( 'INNER JOIN user_affiliations ua ON ua.affiliation_id = affiliations.id' @@ -288,6 +440,10 @@ def molecule_editor profile&.data&.fetch('molecule_editor', false) end + def converter_admin + profile&.data&.fetch('converter_admin', false) + end + def matrix_check_by_name(name) mx = Matrice.find_by(name: name) return false if mx.nil? @@ -345,27 +501,31 @@ def create_text_template end def self.from_omniauth(provider, uid, email, first_name, last_name) - where(omniauth_provider: provider, omniauth_uid: uid).first_or_create do |user| - # update the email, the first_name, and the last_name on every login - user.email = email - user.first_name = first_name - user.last_name = last_name - user.password = Devise.friendly_token[0,20] + user = find_by(email: email) + if user.present? + providers = user.providers || {} + providers[provider] = uid + user.providers = providers + user.save! + else + user = User.new( + email: email, + first_name: first_name, + last_name: last_name, + password: Devise.friendly_token[0, 20], + ) end + user end def link_omniauth(provider, uid) - if User.where(omniauth_provider: provider, omniauth_uid: uid).exists? - return nil - else - self.omniauth_provider = provider - self.omniauth_uid = uid - self.save - end + providers = {} if providers.nil? + providers[provider] = uid + save! end def password_required? - super && omniauth_provider.blank? + super && provider.blank? end private @@ -377,6 +537,7 @@ def password_required? # - delete it def create_all_collection + return if self.type == 'Anonymous' Collection.create(user: self, label: 'All', is_locked: true, position: 0) end @@ -386,8 +547,40 @@ def new_user_text_template def create_chemotion_public_collection return unless self.type == 'Person' + chemotion_user = User.chemotion_user + # Collection.create(user: self, label: 'chemotion.net', is_locked: true, position: 1) + Collection.find_or_create_by(user: self, label: 'ELN Gate', is_locked: true, position: 1) + Collection.find_or_create_by(user: self, label: 'My Data', is_locked: true, position: 2) + + sys_published_by = Collection.find_or_create_by(user_id: chemotion_user.id, label: 'Published by') + sys_pending_from = Collection.find_or_create_by(user_id: chemotion_user.id, label: 'Pending Publication from') + sys_versions_from = Collection.find_or_create_by(user_id: chemotion_user.id, label: 'New Versions from') + sys_reviewing_from = Collection.find_or_create_by(user_id: chemotion_user.id, label: 'Reviewing Publication from') + sys_ready_publish_from = Collection.find_or_create_by(user_id: chemotion_user.id, label: 'Embargoed Publications from') + + sys_reviewing_collection = self.reviewing_collection || Collection.create(user: chemotion_user, label: 'Reviewing', ancestry: "#{sys_reviewing_from.id}") + sys_pending_collection = self.pending_collection || Collection.create(user: chemotion_user, label: 'Pending Publications', ancestry: "#{sys_pending_from.id}") + sys_versions_collection = self.versions_collection || Collection.create(user: chemotion_user, label: 'New Versions', ancestry: "#{sys_versions_from.id}") + sys_published_collection = self.published_collection || Collection.create(user: chemotion_user, label: 'Published Elements', ancestry: "#{sys_published_by.id}") + sys_publication_embargo_collection = self.publication_embargo_collection || Collection.create(user: chemotion_user, label: 'Embargoed Publications', ancestry: "#{sys_ready_publish_from.id}") + + root_label = "with %s" %chemotion_user.name_abbreviation + root_collection_attributes = { + label: root_label, + user: self, + shared_by_id: chemotion_user.id, + is_locked: true, + is_shared: true + } + rc = Collection.find_or_create_by(root_collection_attributes) - Collection.create(user: self, label: 'chemotion-repository.net', is_locked: true, position: 1) + SyncCollectionsUser.find_or_create_by(user: self, shared_by_id: chemotion_user.id, collection_id: Collection.public_collection_id, permission_level: 0, sample_detail_level: 10, reaction_detail_level: 10, fake_ancestry: rc.id.to_s) + SyncCollectionsUser.find_or_create_by(user: self, shared_by_id: chemotion_user.id, collection_id: Collection.scheme_only_reactions_collection.id, permission_level: 0, sample_detail_level: 10, reaction_detail_level: 10, fake_ancestry: rc.id.to_s) + SyncCollectionsUser.find_or_create_by(user: self, shared_by_id: chemotion_user.id, collection_id: sys_published_collection.id, permission_level: 0, sample_detail_level: 10, reaction_detail_level: 10, fake_ancestry: rc.id.to_s) + SyncCollectionsUser.find_or_create_by(user: self, shared_by_id: chemotion_user.id, collection_id: sys_pending_collection.id, permission_level: 0, sample_detail_level: 10, reaction_detail_level: 10, fake_ancestry: rc.id.to_s) + SyncCollectionsUser.find_or_create_by(user: self, shared_by_id: chemotion_user.id, collection_id: sys_versions_collection.id, permission_level: 3, sample_detail_level: 10, reaction_detail_level: 10, fake_ancestry: rc.id.to_s) + SyncCollectionsUser.find_or_create_by(user: self, shared_by_id: chemotion_user.id, collection_id: sys_reviewing_collection.id, permission_level: 3, sample_detail_level: 10, reaction_detail_level: 10, fake_ancestry: rc.id.to_s) + SyncCollectionsUser.find_or_create_by(user: self, shared_by_id: chemotion_user.id, collection_id: sys_publication_embargo_collection.id, permission_level: 0, sample_detail_level: 10, reaction_detail_level: 10, fake_ancestry: rc.id.to_s) end def set_account_active @@ -406,12 +599,13 @@ def send_welcome_email def delete_data # TODO: logic to check if user can be really destroy or which data can be deleted count = samples.count - # + self.reactions.count - # + self.wellplates.count - # + self.screens.count - # + self.research_plans.count + # + self.reactions.count + # + self.wellplates.count + # + self.screens.count + # + self.research_plans.count update_columns(email: "#{id}_#{name_abbreviation}@deleted") update_columns(name_abbreviation: nil) if count.zero? + update_columns(providers: nil) end end diff --git a/app/models/users_collaborator.rb b/app/models/users_collaborator.rb new file mode 100644 index 000000000..7db9e81e2 --- /dev/null +++ b/app/models/users_collaborator.rb @@ -0,0 +1,11 @@ +# == Schema Information +# +# Table name: users_collaborators +# +# id :integer not null, primary key +# user_id :integer +# collaborator_id :integer +# + +class UsersCollaborator < ApplicationRecord +end diff --git a/app/packs/entrypoints/application.js b/app/packs/entrypoints/application.js index f8b16999e..29655a565 100644 --- a/app/packs/entrypoints/application.js +++ b/app/packs/entrypoints/application.js @@ -12,4 +12,10 @@ var OmniauthCredential = require('../src/components/sso/OmniauthCredential'); var UserCounter = require('../src/components/elements/UserCounter'); var ScifinderCredential = require('../src/components/scifinder/ScifinderCredential'); var mydb = require('../src/components/App'); - +var UserCounter = require('../src/components/elements/UserCounter'); +var RepoNewsEditor = require('../src/libHome/RepoNewsEditor'); +var RepoNewsReader = require('../src/libHome/RepoNewsReader'); +var RepoHowToEditor = require('../src/libHome/RepoHowToEditor'); +var RepoHowToReader = require('../src/libHome/RepoHowToReader'); +var LoginOptions = require('../src/components/sso/LoginOptions'); +var ConverterAdmin = require('../src/admin/converter/ConverterAdmin'); diff --git a/app/packs/src/admin/AdminHome.js b/app/packs/src/admin/AdminHome.js index 8dc0f649c..74929a677 100644 --- a/app/packs/src/admin/AdminHome.js +++ b/app/packs/src/admin/AdminHome.js @@ -19,7 +19,6 @@ import SegmentElementAdmin from './SegmentElementAdmin'; import DatasetElementAdmin from './DatasetElementAdmin'; import DelayedJobs from './DelayedJobs'; import TemplateManagement from './TemplateManagement'; -import ConverterAdmin from './converter/AdminApp'; class AdminHome extends React.Component { constructor(props) { @@ -78,8 +77,6 @@ class AdminHome extends React.Component { return this.renderTemplateManagement(); } else if (pageIndex === 13) { return this.renderDelayedJobs(); - } else if (pageIndex === 14) { - return this.renderConverterAdmin(); } return (
); @@ -100,7 +97,6 @@ class AdminHome extends React.Component { Groups & Devices Data Collector NoVNC Settings - Converter Profiles UI features Text Templates Generic Elements (BETA) @@ -206,15 +202,6 @@ class AdminHome extends React.Component { ); } - renderConverterAdmin() { - const { contentClassName } = this.state; - return ( - - - - ); - } - renderTemplateManagement() { const { contentClassName } = this.state; return ( diff --git a/app/packs/src/admin/TemplateManagement.js b/app/packs/src/admin/TemplateManagement.js index 0fc151d53..7cf5886ee 100644 --- a/app/packs/src/admin/TemplateManagement.js +++ b/app/packs/src/admin/TemplateManagement.js @@ -366,7 +366,7 @@ export default class TemplateManagement extends React.Component { {idx + 1} {g.name} - {this.state.reportTemplateTypes.find(({ value }) => value === g.report_type).label} + {(this.state.reportTemplateTypes.find(({ value }) => value === g.report_type) || {}).label} {g.id} diff --git a/app/packs/src/admin/UserManagement.js b/app/packs/src/admin/UserManagement.js index c65eb8ed9..4fd8421f8 100644 --- a/app/packs/src/admin/UserManagement.js +++ b/app/packs/src/admin/UserManagement.js @@ -52,6 +52,8 @@ const confirmUserTooltip = confirm this account (confirm email:
{email}
); const disableTooltip = lock this account; const enableTooltip = unlock this account; +const converterEnableTooltip = Enable Converter profiles editing for this user (currently disabled); +const converterDisableTooltip = Disable Converter profiles editing for this user (currently enabled); const templateModeratorEnableTooltip = Enable Ketcher template editing for this user (currently disabled); const templateModeratorDisableTooltip = Disable Ketcher template editing for this user (currently enabled); const moleculeModeratorEnableTooltip = Enable editing the representation of the global molecules for this user (currently disabled); @@ -152,6 +154,15 @@ export default class UserManagement extends React.Component { }); } + handleConverterAdmin(id, isConverterAdmin) { + AdminFetcher.updateAccount({ user_id: id, converter_admin: !isConverterAdmin }) + .then((result) => { + this.handleFetchUsers(); + const message = isConverterAdmin === true ? 'Disable Converter profiles editing for this user' : 'Enable Converter profiles editing for this user'; + alert(message); + }); + } + handleTemplatesModerator(id, isTemplatesModerator) { AdminFetcher.updateAccount({ user_id: id, is_templates_moderator: !isTemplatesModerator }) .then((result) => { @@ -851,6 +862,16 @@ export default class UserManagement extends React.Component {
  + +