Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug fixes and a few maDMP features for v3.0.1 #2769

Merged
merged 75 commits into from
Dec 17, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
c22517f
added new validation_service for the api v1
briri Nov 9, 2020
524a442
add ability to export a plan as json from the download page (#2719)
briri Nov 10, 2020
9f82f4b
Bug fix for updating identifiers, update fundref DOI base URL and Cre…
briri Nov 10, 2020
6d3e5b0
Provide admins with a way to regenerate their API token (#2718)
briri Nov 11, 2020
8d17654
use relation org.plans instead of method, which works faster and more…
nicolasfranck Nov 16, 2020
42dff6e
use request.host instead of relying on possibly faulty/internal hostname
nicolasfranck Nov 18, 2020
cf790b0
Merge branch 'development' of https://github.com/DMPRoadmap/roadmap i…
briri Nov 18, 2020
de49074
added version question to issue_template.md
briri Nov 18, 2020
93fa87c
fix section sort
nicolasfranck Nov 19, 2020
0c27921
Merge branch 'master' into development
briri Nov 19, 2020
11d9592
Merge branch 'development' into fix_section_sort
briri Nov 19, 2020
617aca3
Merge branch 'development' into fix_host
briri Nov 19, 2020
e419c95
Merge branch 'development' into fix_issue_2724
briri Nov 19, 2020
07feb77
whoops, fixed typo
nicolasfranck Nov 19, 2020
660479e
Merge branch 'fix_section_sort' of github.com:nicolasfranck/roadmap i…
nicolasfranck Nov 19, 2020
46072f1
changes to README to prompt github-actions run
xsrust Nov 23, 2020
0abf99d
Add org_id to api_clients table and refactor of API v1 services (#2725)
briri Nov 23, 2020
2f0e0ea
ask github-actions to generate the credentials file, update example f…
xsrust Nov 23, 2020
a7d4899
add new step after bundler installed for generating credentials
xsrust Nov 23, 2020
790ab8e
call to bundle exec after setting env variable
xsrust Nov 23, 2020
b04d875
Merge branch 'development' into github_actions/credentials_not_loading
xsrust Nov 23, 2020
efba836
copy github_actions credentials changes over to mysql, remove unusued…
xsrust Nov 23, 2020
b41cafc
Merge pull request #2743 from DMPRoadmap/github_actions/credentials_n…
briri Nov 23, 2020
df60142
Merge branch 'development' into fix_host
briri Nov 23, 2020
ff2729a
Merge pull request #2729 from nicolasfranck/fix_host
briri Nov 23, 2020
0afeb25
Minor change to external API services to use `active?` instead of `ac…
briri Nov 24, 2020
39ccd16
Merge branch 'development' into fix_section_sort
xsrust Nov 24, 2020
3243a20
Merge branch 'development' into fix_issue_2724
xsrust Nov 24, 2020
05acc13
Merge pull request #2735 from nicolasfranck/fix_section_sort
briri Nov 25, 2020
d773fc9
Merge branch 'development' into fix_issue_2724
briri Nov 25, 2020
974f921
fix for issue with Org selection. Strong params were always resulting…
briri Nov 25, 2020
bbe0d5a
Merge branch 'master' of https://github.com/DMPRoadmap/roadmap into m…
briri Dec 1, 2020
1fba8c4
fix issue with saving changes to Org links
briri Dec 1, 2020
2d84496
@orgs should be orgs; use shib-ds[org-id]
nicolasfranck Dec 2, 2020
132c87d
open omniauth orcid controller in same window
nicolasfranck Dec 2, 2020
9b69315
Merge branch 'development' into fix_issue_2752
briri Dec 2, 2020
7f54606
Merge branch 'development' into fix_issue_2686
briri Dec 2, 2020
da18d16
Merge branch 'development' into fix-org-links
briri Dec 2, 2020
f70046f
update spec
nicolasfranck Dec 2, 2020
5e584e4
fix spec
nicolasfranck Dec 2, 2020
1e6fdba
remove shib-ds logic and simplify;
nicolasfranck Dec 3, 2020
31571bc
Revert "use relation org.plans instead of method, which works faster …
nicolasfranck Dec 3, 2020
09c5095
cache org.plans for now to speed up page load (for now)
nicolasfranck Dec 3, 2020
965b75f
Adding Research Outputs (DB and model components only) (#2739)
briri Dec 8, 2020
c28ba50
Bugfix for issue with setting plan visibility (#2759)
briri Dec 8, 2020
62ddaf9
Merge branch 'development' into fix-org-links
xsrust Dec 8, 2020
47c26e3
Merge branch 'development' into fix_issue_2724
briri Dec 8, 2020
d64d5d9
Merge pull request #2726 from nicolasfranck/fix_issue_2724
briri Dec 8, 2020
3450879
Merge branch 'development' into fix_issue_2752
briri Dec 8, 2020
847c280
Merge branch 'development' into fix_issue_2686
briri Dec 8, 2020
8870e74
Merge branch 'development' into fix-org-links
briri Dec 8, 2020
b2faeaf
include gem uglifier for production (#2741)
nicolasfranck Dec 9, 2020
d800970
Merge pull request #2754 from nicolasfranck/fix_issue_2752
briri Dec 9, 2020
44afe4c
Merge pull request #2753 from nicolasfranck/fix_issue_2686
briri Dec 9, 2020
f19b804
Merge branch 'development' into fix-org-links
briri Dec 14, 2020
c9a385c
fixed issue with saving changes to org_types
briri Dec 14, 2020
be96275
Merge branch 'fix-org-links' of https://github.com/DMPRoadmap/roadmap…
briri Dec 14, 2020
8054ec8
fixed tests
briri Dec 14, 2020
2513fad
fixed another test on orgs_controller
briri Dec 14, 2020
c3b2278
fixed another test on orgs_controller
briri Dec 14, 2020
20d7090
fixed another test on orgs_controller
briri Dec 14, 2020
9a6e139
Merge pull request #2751 from DMPRoadmap/fix-org-links
raycarrick-ed Dec 15, 2020
1ded3ee
convert api_clients:last_access from date to datetime
nicolasfranck Dec 16, 2020
ff8085d
disable recaptcha when disabled in configuration
nicolasfranck Dec 16, 2020
233c5e2
Merge pull request #2765 from nicolasfranck/fix_contact_us_recaptcha
briri Dec 16, 2020
546820a
Merge branch 'fix_issue_2742' of https://github.com/nicolasfranck/roa…
briri Dec 16, 2020
1933351
updated schema to reflect change to api_clients.last_access
briri Dec 16, 2020
a9a2504
added tinymce.css to public/tinymce dir
briri Dec 16, 2020
6fcbf2a
updated JS to point to public/tinymce/tinymce.css
briri Dec 16, 2020
e7b0fc2
Merge pull request #2767 from DMPRoadmap/add-tinymce-css
raycarrick-ed Dec 17, 2020
678cab2
Merge branch 'development' into issue2742
raycarrick-ed Dec 17, 2020
5e8ae19
Merge pull request #2766 from DMPRoadmap/issue2742
raycarrick-ed Dec 17, 2020
e82041b
Merge branch 'master' into development
briri Dec 17, 2020
a074260
Merge branch 'master' into development
briri Dec 17, 2020
2ae57ff
updated brakeman ignores
briri Dec 17, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/mysql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ jobs:
DB_ADAPTER: mysql2
MYSQL_PWD: root
RAILS_ENV: test
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}

steps:
# Checkout the repo
Expand Down Expand Up @@ -40,7 +39,6 @@ jobs:
cp config/database.yml.sample config/database.yml
cp config/initializers/contact_us.rb.example config/initializers/contact_us.rb
cp config/initializers/wicked_pdf.rb.example config/initializers/wicked_pdf.rb
cp config/credentials.yml.enc.workflow config/credentials.yml.enc

# Try to retrieve the gems from the cache
- name: 'Cache Gems'
Expand All @@ -57,6 +55,11 @@ jobs:
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3 --without pgsql rollbar aws

- name: 'Setup Credentials'
run: |
# generate a default credential file and key
EDITOR='echo "$(cat config/credentials.yml.example)" >' bundle exec rails credentials:edit

# Try to retrieve the yarn JS dependencies from the cache
- name: 'Cache Yarn Packages'
uses: actions/cache@v1
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/postgres.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ jobs:
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:@localhost:5432/roadmap_test
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}

steps:
# Checkout the repo
Expand Down Expand Up @@ -57,7 +56,6 @@ jobs:
cp config/database.yml.sample config/database.yml
cp config/initializers/contact_us.rb.example config/initializers/contact_us.rb
cp config/initializers/wicked_pdf.rb.example config/initializers/wicked_pdf.rb
cp config/credentials.yml.enc.workflow config/credentials.yml.enc

# Try to retrieve the gems from the cache
- name: 'Cache Gems'
Expand All @@ -74,6 +72,11 @@ jobs:
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3 --without mysql rollbar aws

- name: 'Setup Credentials'
run: |
# generate a default credential file and key
EDITOR='echo "$(cat config/credentials.yml.example)" >' bundle exec rails credentials:edit

# Try to retrieve the yarn JS dependencies from the cache
- name: 'Cache Yarn Packages'
uses: actions/cache@v1
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[![Actions Status](https://github.com/DMPRoadmap/roadmap/workflows/Tests%20-%20PostgreSQL/badge.svg)](https://github.com/DMPRoadmap/roadmap/actions)
[![Actions Status](https://github.com/DMPRoadmap/roadmap/workflows/Tests%20-%20MySQL/badge.svg)](https://github.com/DMPRoadmap/roadmap/actions)

DMP Roadmap is a Data Management Planning tool. Management and development of DMP Roadmap is jointly provided by the Digital Curation Centre (DCC), http://www.dcc.ac.uk/, and the University of California Curation Center (UC3), http://www.cdlib.org/services/uc3/
DMP Roadmap is a Data Management Planning tool. Management and development of DMP Roadmap is jointly provided by the Digital Curation Centre (DCC), http://www.dcc.ac.uk/, and the University of California Curation Center (UC3), http://www.cdlib.org/services/uc3/.

The tool has four main functions:

Expand All @@ -23,7 +23,7 @@ Roadmap is a Ruby on Rails application and you will need to have:
* Rails = 4.2
* MySQL >= 5.0 OR PostgreSQL

Further detail on how to install Ruby on Rails applications are available from the Ruby on Rails site: http://rubyonrails.org
Further detail on how to install Ruby on Rails applications are available from the Ruby on Rails site: http://rubyonrails.org.

Further details on how to install MySQL and create your first user and database. Be sure to follow the instructions for your particular environment.
* Install: http://dev.mysql.com/downloads/mysql/
Expand All @@ -36,10 +36,10 @@ You may also find the following resources handy:
* Ruby on Rails Tutorial Book: http://www.railstutorial.org/

#### Installation
See the [Installation Guide](https://github.com/DMPRoadmap/roadmap/wiki/Installation) on the Wiki
See the [Installation Guide](https://github.com/DMPRoadmap/roadmap/wiki/Installation) on the Wiki.

#### Troubleshooting
See the [Troubleshooting Guide](https://github.com/DMPRoadmap/roadmap/wiki/Troubleshooting) on the Wiki
See the [Troubleshooting Guide](https://github.com/DMPRoadmap/roadmap/wiki/Troubleshooting) on the Wiki.

#### Support
Issues should be reported here on [Github Issues](https://github.com/DMPRoadmap/roadmap/issues)
Expand All @@ -56,7 +56,7 @@ If you would like to contribute to the project. Please follow these steps to sub
* Then create a new Pull Request (PR) from your branch to this project's '_**development**_' branch in GitHub
* The project team will then review your PR and communicate with you to convey any additional changes that would ensure that your work adheres to our guidelines.

See the [Contribution Guide](https://github.com/DMPRoadmap/roadmap/blob/development/CONTRIBUTING.md) on the Wiki for more details
See the [Contribution Guide](https://github.com/DMPRoadmap/roadmap/blob/development/CONTRIBUTING.md) on the Wiki for more details.

#### License
The DMP Roadmap project uses the <a href="./LICENSE.md">MIT License</a>.
2 changes: 1 addition & 1 deletion app/controllers/api/v1/base_api_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def heartbeat

def render_error(errors:, status:)
@payload = { errors: [errors] }
render "/api/v1/error", status: status and return
render "/api/v1/error", status: status
end

private
Expand Down
167 changes: 82 additions & 85 deletions app/controllers/api/v1/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,85 +9,74 @@ class PlansController < BaseApiController
respond_to :json

# GET /api/v1/plans/:id
# rubocop:disable Metrics/AbcSize
def show
plans = Plan.where(id: params[:id]).limit(1)

if plans.any?
if client.is_a?(User)
# If the specified plan does not belong to the org or the owner's org
if plans.first.org_id != client.org_id &&
plans.first.owner&.org_id != client.org_id

# Kaminari pagination requires an ActiveRecord resultset :/
plans = Plan.where(id: nil).limit(1)
end

elsif client.is_a?(ApiClient) && plans.first.api_client_id != client.id &&
!plans.first.publicly_visible?
# Kaminari pagination requires an ActiveRecord resultset :/
plans = Plan.where(id: nil).limit(1)
end

if plans.present? && plans.any?
@items = paginate_response(results: plans)
render "/api/v1/plans/index", status: :ok
else
render_error(errors: [_("Plan not found")], status: :not_found)
end
plans = Api::V1::PlansPolicy::Scope.new(client, Plan).resolve
.where(id: params[:id]).limit(1)

if plans.present? && plans.any?
@items = paginate_response(results: plans)
render "/api/v1/plans/index", status: :ok
else
render_error(errors: [_("Plan not found")], status: :not_found)
end
end
# rubocop:enable Metrics/AbcSize
# rubocop:enable

# POST /api/v1/plans
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/BlockNesting
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def create
dmp = @json.with_indifferent_access.fetch(:items, []).first.fetch(:dmp, {})

# If a dmp_id was passed in try to find it
if dmp[:dmp_id].present? && dmp[:dmp_id][:identifier].present?
scheme = IdentifierScheme.by_name(dmp[:dmp_id][:type]).first
dmp_id = Identifier.where(value: dmp[:dmp_id][:identifier],
identifier_scheme: scheme)
end

# Skip if this is an existing DMP
if dmp_id.present?
render_error(errors: _("Plan already exists. Send an update instead."),
status: :bad_request)
# Do a pass through the raw JSON and check to make sure all required fields
# were present. If not, return the specific errors
errs = Api::V1::JsonValidationService.validation_errors(json: dmp)
render_error(errors: errs, status: :bad_request) and return if errs.any?

# Convert the JSON into a Plan and it's associations
plan = Api::V1::Deserialization::Plan.deserialize(json: dmp)
if plan.present?
save_err = _("Unable to create your DMP")
exists_err = _("Plan already exists. Send an update instead.")
no_org_err = _("Could not determine ownership of the DMP. Please add an
:affiliation to the :contact")

# Try to determine the Plan's owner
owner = determine_owner(client: client, plan: plan)
plan.org = owner.org if owner.present? && plan.org.blank?
render_error(errors: no_org_err, status: :bad_request) and return unless plan.org.present?

# Validate the plan and it's associations and return errors with context
# e.g. 'Contact affiliation name can't be blank' instead of 'name can't be blank'
errs = Api::V1::ContextualErrorService.process_plan_errors(plan: plan)

# The resulting plan (our its associations were invalid)
render_error(errors: errs, status: :bad_request) and return if errs.any?
# Skip if this is an existing DMP
render_error(errors: exists_err, status: :bad_request) and return unless plan.new_record?

# If we cannot save for some reason then return an error
plan = Api::V1::PersistenceService.safe_save(plan: plan)
# rubocop:disable Layout/LineLength
render_error(errors: save_err, status: :internal_server_error) and return if plan.new_record?

# rubocop:enable Layout/LineLength

# If the plan was generated by an ApiClient then associate them
plan.update(api_client_id: client.id) if client.is_a?(ApiClient)

# Invite the Owner if they are a Contributor then attach the Owner to the Plan
owner = invite_contributor(contributor: owner) if owner.is_a?(Contributor)
plan.add_user!(owner.id, :creator)

# Kaminari Pagination requires an ActiveRecord result set :/
@items = paginate_response(results: Plan.where(id: plan.id))
render "/api/v1/plans/index", status: :created
else
# Time prior to JSON parser service call which will create the plan so
# we can stop the creation of duplicate plans below
now = (Time.now - 1.minute)
plan = Api::V1::Deserialization::Plan.deserialize!(json: dmp)

if plan.present?
if plan.created_at.utc < now.utc
render_error(errors: _("Plan already exists. Send an update instead."),
status: :bad_request)

else
# If the plan was generated by an ApiClient then associate them
plan.update(api_client_id: client.id) if client.is_a?(ApiClient)

assign_roles(plan: plan)

# Kaminari Pagination requires an ActiveRecord result set :/
@items = paginate_response(results: Plan.where(id: plan.id))
render "/api/v1/plans/index", status: :created
end
else
render_error(errors: [_("Invalid JSON")], status: :bad_request)
end
render_error(errors: [_("Invalid JSON!")], status: :bad_request)
end
rescue JSON::ParserError
render_error(errors: [_("Invalid JSON")], status: :bad_request)
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/BlockNesting
# rubocop:enable
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength

# GET /api/v1/plans
def index
Expand All @@ -111,33 +100,42 @@ def dmp_params
params.require(:dmp).permit(plan_permitted_params).to_h
end

def assign_roles(plan:)
# Attach all of the authors and then invite them if necessary
owner = nil
plan.contributors.data_curation.each do |contributor|
user = contributor_to_user(contributor: contributor)
next unless user.present?

# Attach the role
role = Role.new(user: user, plan: plan)
role.creator = true if contributor.data_curation?
# We only want one owner/creator so jusst use the 1st contributor
# which should be the contact in the JSON input
owner = contributor if contributor.data_curation?
role.administrator = true if contributor.data_curation? &&
!contributor.present?
role.save
end
def plan_exists?(json:)
return false unless json.present? &&
json[:dmp_id].present? &&
json[:dmp_id][:identifier].present?

scheme = IdentifierScheme.by_name(json[:dmp_id][:type]).first
Identifier.where(value: json[:dmp_id][:identifier], identifier_scheme: scheme).any?
end

# Get the Plan's owner
def determine_owner(client:, plan:)
contact = plan.contributors.select(&:data_curation?).first
# Use the contact if it was sent in and has an affiliation defined
return contact if contact.present? && contact.org.present?

# If the contact has no affiliation defined, see if they are already a User
user = lookup_user(contributor: contact)
return user if user.present?

# Otherwise just return the client
client
end

# rubocop:disable Metrics/AbcSize
def contributor_to_user(contributor:)
def lookup_user(contributor:)
return nil unless contributor.present?

identifiers = contributor.identifiers.map do |id|
{ name: id.identifier_scheme&.name, value: id.value }
end
user = User.from_identifiers(array: identifiers) if identifiers.any?
user = User.find_by(email: contributor.email) unless user.present?
return user if user.present?
user
end

def invite_contributor(contributor:)
return nil unless contributor.present?

# If the user was not found, invite them and attach any know identifiers
names = contributor.name&.split || [""]
Expand All @@ -155,7 +153,6 @@ def contributor_to_user(contributor:)
end
user
end
# rubocop:enable Metrics/AbcSize

end

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/contacts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ class ContactUs::ContactsController < ApplicationController
def create
@contact = ContactUs::Contact.new(params[:contact_us_contact])

unless user_signed_in?
if !user_signed_in? && Rails.configuration.x.recaptcha.enabled
unless verify_recaptcha(model: @contact) && @contact.save
flash[:alert] = _("Captcha verification failed, please retry.")
render_new_page and return
Expand Down
19 changes: 9 additions & 10 deletions app/controllers/orgs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,7 @@ def admin_update
authorize @org
@org.logo = attrs[:logo] if attrs[:logo]
tab = (attrs[:feedback_enabled].present? ? "feedback" : "profile")
if attrs[:org_links].present?
@org.links = ActiveSupport::JSON.decode(attrs[:org_links])
attrs.delete(:org_links)
end
@org.links = ActiveSupport::JSON.decode(params[:org_links]) if params[:org_links].present?

# Only allow super admins to change the org types and shib info
if current_user.can_super_admin?
Expand Down Expand Up @@ -105,6 +102,7 @@ def shibboleth_ds
# Display the custom Shibboleth discovery service page.
@orgs = Identifier.by_scheme_name("shibboleth", "Org")
.sort { |a, b| a.identifiable.name <=> b.identifiable.name }
.map(&:identifiable)

# Disabling the rubocop check here because it would not be clear what happens
# if the ``@orgs` array has items ... it renders the shibboleth_ds view
Expand All @@ -120,10 +118,10 @@ def shibboleth_ds
# POST /orgs/shibboleth_ds
# rubocop:disable Metrics/AbcSize
def shibboleth_ds_passthru
if !shib_params["shib-ds"][:org_name].blank?
session["org_id"] = shib_params["shib-ds"][:org_name]
if !shib_params[:org_id].blank?
session["org_id"] = shib_params[:org_id]

org = Org.where(id: shib_params["shib-ds"][:org_id])
org = Org.where(id: shib_params[:org_id])
shib_entity = Identifier.by_scheme_name("shibboleth", "Org")
.where(identifiable: org)

Expand Down Expand Up @@ -211,14 +209,15 @@ def search
def org_params
params.require(:org)
.permit(:name, :abbreviation, :logo, :contact_email, :contact_name,
:remove_logo, :org_type, :managed, :feedback_enabled, :org_links,
:remove_logo, :managed, :feedback_enabled, :org_links,
:funder, :institution, :organisation,
:feedback_email_msg, :org_id, :org_name, :org_crosswalk,
identifiers_attributes: %i[identifier_scheme_id value],
tracker_attributes: %i[code])
tracker_attributes: %i[code id])
end

def shib_params
params.permit("shib-ds": %i[org_id org_name])
params.permit("org_id")
end

def search_params
Expand Down
6 changes: 6 additions & 0 deletions app/controllers/plan_exports_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def show
format.text { show_text }
format.docx { show_docx }
format.pdf { show_pdf }
format.json { show_json }
end
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
Expand Down Expand Up @@ -91,6 +92,11 @@ def show_pdf
}
end

def show_json
json = render_to_string(partial: "/api/v1/plans/show", locals: { plan: @plan })
render json: "{\"dmp\":#{json}}"
end

def file_name
# Sanitize bad characters and replace spaces with underscores
ret = @plan.title
Expand Down
Loading