From d6a61d8e4d64855eeb1602a418ff0fd1392d54c0 Mon Sep 17 00:00:00 2001 From: jasensch Date: Thu, 12 Sep 2019 14:55:21 +1000 Subject: [PATCH 1/3] Increment the version in build.gradle to be 0.7.0-SNAPSHOT as agreed with Temi; Updated README.md file to reflect the latest instructions to set up a local development environment; Moved groovy files to their defined package (their compiled bytecode ends up in the defined package and not the current folder in the built war), to fix Errors messages detected by the Groovy Plug-in for Eclipse; Updated the defined packaged of ChartController.groovy to match the folder it is currently in ... there was no direct reference to this class and calling code in UrlMappings.groovy (in a different package) uses controller: 'chart' to refer to the ChartController, so changing the defined package is not expected to cause any issues; In build.gradle added a pathingJar to address https://github.com/gradle/gradle/issues/1989; --- README.md | 138 +++++++++++++++--- build.gradle | 22 ++- .../org/ala/phyloviz/ChartController.groovy | 2 +- .../org/ala/phyloviz}/ContextualWidget.groovy | 0 .../ala/phyloviz}/ConvertDomainObject.groovy | 0 .../org/ala/phyloviz}/ConvertSandbox.groovy | 0 .../ala/phyloviz}/ConvertTreeToObject.groovy | 0 .../ala/phyloviz}/EnvironmentalWidget.groovy | 0 .../{ => au/org/ala/phyloviz}/Nexson.groovy | 0 .../{ => au/org/ala/phyloviz}/PDWidget.groovy | 0 .../org/ala/phyloviz}/UserDetails.groovy | 0 .../org/ala/phyloviz}/WidgetFactory.groovy | 0 .../org/ala/phyloviz}/WidgetInterface.groovy | 0 .../org/ala/phyloviz}/WidgetType.groovy | 0 .../org/ala/soils2sat}/LayerDefinition.groovy | 0 .../org/ala/soils2sat}/LayerTreeNode.groovy | 0 16 files changed, 137 insertions(+), 25 deletions(-) rename src/main/groovy/{ => au/org/ala/phyloviz}/ContextualWidget.groovy (100%) rename src/main/groovy/{ => au/org/ala/phyloviz}/ConvertDomainObject.groovy (100%) rename src/main/groovy/{ => au/org/ala/phyloviz}/ConvertSandbox.groovy (100%) rename src/main/groovy/{ => au/org/ala/phyloviz}/ConvertTreeToObject.groovy (100%) rename src/main/groovy/{ => au/org/ala/phyloviz}/EnvironmentalWidget.groovy (100%) rename src/main/groovy/{ => au/org/ala/phyloviz}/Nexson.groovy (100%) rename src/main/groovy/{ => au/org/ala/phyloviz}/PDWidget.groovy (100%) rename src/main/groovy/{ => au/org/ala/phyloviz}/UserDetails.groovy (100%) rename src/main/groovy/{ => au/org/ala/phyloviz}/WidgetFactory.groovy (100%) rename src/main/groovy/{ => au/org/ala/phyloviz}/WidgetInterface.groovy (100%) rename src/main/groovy/{ => au/org/ala/phyloviz}/WidgetType.groovy (100%) rename src/main/groovy/{ => au/org/ala/soils2sat}/LayerDefinition.groovy (100%) rename src/main/groovy/{ => au/org/ala/soils2sat}/LayerTreeNode.groovy (100%) diff --git a/README.md b/README.md index 0eabd8ee..8753db5e 100644 --- a/README.md +++ b/README.md @@ -2,52 +2,144 @@ ## Installing for development purposes -There are two parts to get phylolink installed on your system. -First, you need to install the dependencies. Second, running phylolink locally, for example, on intellij. +### Install required software -Installing dependencies on local virtual machine +Ensure the correct versions of vagrant and ansible are installed as detailed on https://github.com/AtlasOfLivingAustralia/ala-install. As at Sep 2019 this involved: + +For APT: +``` +$ sudo apt-get install software-properties-common python-dev git python-pip +$ sudo pip install setuptools +$ sudo pip install -I ansible==2.7.0 +``` + +For OSX: +``` +$ sudo easy_install pip +$ sudo pip install -I ansible==2.7.0 +``` + +Then install vagrant for Debian/Ubuntu: +``` +$ sudo apt-get install virtualbox virtualbox-dkms virtualbox-qt +$ cd ~/Downloads +$ wget https://releases.hashicorp.com/vagrant/2.0.4/vagrant_2.0.4_x86_64.deb +$ sudo dpkg -i vagrant_2.0.4_x86_64.deb +$ vagrant plugin install vagrant-disksize +``` + +### Installing the phylolink application to the local vagrant container ``` $ cd ~ $ git clone https://github.com/AtlasOfLivingAustralia/ala-install.git -$ cd ~/ala-install -$ cd vagrant/ubuntu-trusty +$ cd ~/ala-install/vagrant/ubuntu-xenial +``` + +The default configuration for the phylolink vagrant container is to assign 8GB of RAM. If you wish to reduce this: +``` +$ sudo vim ~/ala-install/vagrant/ubuntu-xenial/Vagrantfile + and change the line v.memory = 8192. I have had this as low as 1536 and it has been fine for a local environemnt. +``` + +Please note that the first execution of starting up the vagrant container, downloads the Ubuntu image which can take 20 minutes or more. +``` +$ cd ~/ala-install/vagrant/ubuntu-xenial $ vagrant up -$ cd ../../ansible +``` + +The ALA ansible inventories (in the ansible/inventories/vagrant directory) refer to the VM as 'vagrant1' rather than the IP address. For this to work, you will need to add an entry to your ```/etc/hosts``` file. The IP address of the vagrant container is defined in the ```~/ala-install/vagrant/ubuntu-xenial/Vagrantfile``` which is currently ```config.vm.network :private_network, ip: "10.1.1.4"```, so the hosts file entry is: +``` +10.1.1.4 vagrant1 phylolink.vagrant1.ala.org.au +``` + +The ansible scripts can then be run to provision the vagrant container and deploy the phylolink application and its dependencies (including its postgres database): +``` +$ cd ~/ala-install/ansible $ ansible-playbook phylolink.yml -i inventories/vagrant/phylolink-vagrant --sudo --private-key ~/.vagrant.d/insecure_private_key -u vagrant ``` -Create an entry into hosts file. +Please note you can safely ignore the error message: +``` +RUNNING HANDLER [common : restart tomcat] +fatal: [vagrant1]: FAILED! => {"changed": false, "msg": "Could not find the requested service tomcat7: host"} +``` +as tomcat is not installed. Instead tomcat is embedded in the phylolink application "fat jar". If you want to avoid the error you will need to edit ```~/ala-install/ansible/roles/nameindex/tasks/main.yml``` and comment out the two instances of: ``` -10.1.1.2 phylolink.vagrant1.ala.org.au + notify: + - restart tomcat ``` -Save the config file as ```phylolink-config.properties``` in directory ```/data/phylolink/config/```. -Install postgres on your local machine. create role with password and database according to the information given in ```phylolink-config.properties```. +To log into the vagrant container perform the following commands: +``` +$ cd ~/ala-install/vagrant/ubuntu-xenial +$ vagrant ssh +``` +The phylolink application externalises its configuration into the file ```/data/phylolink/config/phylolink-config.properties``` on the vagrant container. +The webservice.apiKey property will need to be updated, please speak to a member of the project team to get a valid value. Note this value is sensitive, which is why it is not freely available. Once the webservice.apiKey property is update you will need to stop and start the application and then confirm its status: +``` +$ sudo vim /data/phylolink/config/phylolink-config.properties +$ sudo systemctl stop phylolink +$ sudo systemctl start phylolink +$ sudo systemctl status phylolink +``` -## Installing the DB locally +### Connecting to the phyolink postgres db -These instructions are for Mac OSX Yosemite. Adapt them as necessary for your environment. +Install a postgres client on your machine e.g. https://www.pgadmin.org/ +You can then try and to connect to +``` +server: vagrant1 +port: 5432 +with user name and password as defined in /data/phylolink/config/phylolink-config.properties in the dataSource.username and dataSource.password properties +``` +and view the database with name phyolink. -1. Install Postgres (e.g. using Homebrew: ```brew install postgresql```) -1. Create the data directory (e.g. /data/postgres) -1. Make sure the owner of the directory is not a super user (i.e. not root) and is the same as the user who will run the postgres server -1. Make sure the directory permissions are 0700 (e.g. ```sudo chmod 700 /data/postgres```) -1. Configure the database: ```initdb -D /data/postgres``` -1. Start the server: ```postgres -D /data/postgres``` -1. Create the user: ```createuser phylo -P``` (-P prompts for a password: if not specified, the user will not have a password) -1. Create the database: ```createdb phylolink``` +This will most probably fail the first time. In the error message an IP address should be listed. Following the instructions on http://theneum.com/blog/connect-to-vagrant-postgres-database-via-pgadmin3-on-mac/, on the vagrant container ssh: +``` +$ sudo vim /etc/postgresql/9.6/main/postgresql.conf + uncomment listen_addresses and set to be = '*' +$ sudo vim /etc/postgresql/9.6/main/pg_hba.conf + add the line: + host all all [IP address in error message]/16 md5 +$ sudo /etc/init.d/postgresql restart +``` -## Installing on local virtual machine with vagrant +### Testing that the installation of the phylolink application on the vagrant container has succeeded: +View http://phylolink.vagrant1.ala.org.au in a browser. For the standard ansible install, this is running the phyolink grails application on the vagrant container which has been compiled as a "fat jar" with an embedded tomcat container in the location ```/opt/atlas/phylolink/phylolink.jar```. This is started by a systemd service called phylolink (which is defined in the standard ubuntu location of ```/etc/systemd/system/phylolink.service```). To view the status of the phylolink service run the following command: +``` +$ sudo systemctl status phylolink +``` -Ansible scripts are in the [ala-install](https://github.com/AtlasOfLivingAustralia/ala-install) repository +### Running phylolink from an IDE for rapid development +* Clone phylolink from GitHub e.g. ``` -ansible-playbook phylolink.yml -i ala-install/ansible/inventories/vagrant/phylolink-vagrant --sudo --private-key ~/.vagrant.d/insecure_private_key -u vagrant +$ cd ~ +$ git clone https://github.com/AtlasOfLivingAustralia/phylolink.git +``` +* Install java 1.8 if you don't already have it +* Install the grails version specified in ~/phylolink/gradle.properties. At the time of writing that is version 3.3.2. Following http://grails.asia/grails-3-tutorial-setup-your-windows-development-environment/ or http://docs.grails.org/3.3.2/guide/single.html. + * Add Gradle Plugin to your IDE if you don't already have it (for eclipse see https://www.vogella.com/tutorials/EclipseGradle/article.html) + * Add Groovy Plugin to your IDE if you don't already have it (for eclipse see https://github.com/groovy/groovy-eclipse/wiki) + * Import the gradle project into your IDE (e.g. File -> Import -> Gradle -> Root Folder ~/phylolink) +* Create the following directory structure: ```/data/phylolink/config``` +* Copy all files in ~/ala-install/ansible/roles/phylolink/templates to /data/phylolink/config and substitute the variable values (speak to someone from the development team) +* Install the name index by + * Download the latest names index file from http://biocache.ala.org.au/archives/nameindexes/ (At the time of writing the latest one was 20190213/namematching-20190213.tgz) + * Unzip it to /data/lucene/namematching so that you end up with the cb, id, irmng and vernacular sub folders + * NOTE: the name.index.location property in /data/phylolink/config/phylolink-config.properties must match the /data/lucene/namematching folder name +* Add a hosts file entry: 127.0.0.1 devt.ala.org.au +* Start the grails application: ``` +$ cd ~/phylolink +$ grails run-app -port=8090 +``` +* View http://devt.ala.org.au:8090/phylolink in a browser to test the application which is running the grails part of the application locally and consuming the web services and postgres db running in the vagrant container. ## Installing on production ``` $ cd ala-install/ansible $ ansible-playbook phylolink.yml -i ../../ansible-inventories/phylolink-prod -s --ask-sudo-pass ``` + diff --git a/build.gradle b/build.gradle index c8d5bb13..96378f40 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,7 @@ plugins { id "com.gorylenko.gradle-git-properties" version "1.4.17" } -version "0.6.5" +version "0.7.0-SNAPSHOT" group "au.org.ala" apply plugin:"eclipse" @@ -94,7 +94,27 @@ dependencies { compile files('lib/jade.jar') } +// Based on https://github.com/gradle/gradle/issues/1989 and https://tuhrig.de/gradles-bootrun-and-windows-command-length-limit/ and +// Enhanced to handle spaces in the paths as described on https://stackoverflow.com/questions/18659774/how-do-i-handle-files-with-spaces-in-the-classpath-in-manifest-mf +task pathingJarForWindows(type: Jar) { + dependsOn configurations.runtime + appendix = 'pathing' + + doFirst { + manifest { + attributes "Class-Path": configurations.runtime.files.collect { + it.toURL().toString().replaceAll(' ', '%20') //.replaceFirst(/file:/+/, '/') + }.join(' ') + } + } +} + bootRun { + dependsOn pathingJarForWindows + doFirst { + logger.error('pathingJarForWindows.archivePath=' + pathingJarForWindows.archivePath) + classpath = files("$buildDir/classes/main", "$buildDir/resources/main", pathingJarForWindows.archivePath) + } jvmArgs('-Dspring.output.ansi.enabled=always') addResources = true } diff --git a/grails-app/controllers/au/org/ala/phyloviz/ChartController.groovy b/grails-app/controllers/au/org/ala/phyloviz/ChartController.groovy index 4e6cb306..73865d9b 100644 --- a/grails-app/controllers/au/org/ala/phyloviz/ChartController.groovy +++ b/grails-app/controllers/au/org/ala/phyloviz/ChartController.groovy @@ -1,4 +1,4 @@ -package au.org.ala.phylolink +package au.org.ala.phyloviz import grails.converters.JSON import groovy.json.JsonSlurper diff --git a/src/main/groovy/ContextualWidget.groovy b/src/main/groovy/au/org/ala/phyloviz/ContextualWidget.groovy similarity index 100% rename from src/main/groovy/ContextualWidget.groovy rename to src/main/groovy/au/org/ala/phyloviz/ContextualWidget.groovy diff --git a/src/main/groovy/ConvertDomainObject.groovy b/src/main/groovy/au/org/ala/phyloviz/ConvertDomainObject.groovy similarity index 100% rename from src/main/groovy/ConvertDomainObject.groovy rename to src/main/groovy/au/org/ala/phyloviz/ConvertDomainObject.groovy diff --git a/src/main/groovy/ConvertSandbox.groovy b/src/main/groovy/au/org/ala/phyloviz/ConvertSandbox.groovy similarity index 100% rename from src/main/groovy/ConvertSandbox.groovy rename to src/main/groovy/au/org/ala/phyloviz/ConvertSandbox.groovy diff --git a/src/main/groovy/ConvertTreeToObject.groovy b/src/main/groovy/au/org/ala/phyloviz/ConvertTreeToObject.groovy similarity index 100% rename from src/main/groovy/ConvertTreeToObject.groovy rename to src/main/groovy/au/org/ala/phyloviz/ConvertTreeToObject.groovy diff --git a/src/main/groovy/EnvironmentalWidget.groovy b/src/main/groovy/au/org/ala/phyloviz/EnvironmentalWidget.groovy similarity index 100% rename from src/main/groovy/EnvironmentalWidget.groovy rename to src/main/groovy/au/org/ala/phyloviz/EnvironmentalWidget.groovy diff --git a/src/main/groovy/Nexson.groovy b/src/main/groovy/au/org/ala/phyloviz/Nexson.groovy similarity index 100% rename from src/main/groovy/Nexson.groovy rename to src/main/groovy/au/org/ala/phyloviz/Nexson.groovy diff --git a/src/main/groovy/PDWidget.groovy b/src/main/groovy/au/org/ala/phyloviz/PDWidget.groovy similarity index 100% rename from src/main/groovy/PDWidget.groovy rename to src/main/groovy/au/org/ala/phyloviz/PDWidget.groovy diff --git a/src/main/groovy/UserDetails.groovy b/src/main/groovy/au/org/ala/phyloviz/UserDetails.groovy similarity index 100% rename from src/main/groovy/UserDetails.groovy rename to src/main/groovy/au/org/ala/phyloviz/UserDetails.groovy diff --git a/src/main/groovy/WidgetFactory.groovy b/src/main/groovy/au/org/ala/phyloviz/WidgetFactory.groovy similarity index 100% rename from src/main/groovy/WidgetFactory.groovy rename to src/main/groovy/au/org/ala/phyloviz/WidgetFactory.groovy diff --git a/src/main/groovy/WidgetInterface.groovy b/src/main/groovy/au/org/ala/phyloviz/WidgetInterface.groovy similarity index 100% rename from src/main/groovy/WidgetInterface.groovy rename to src/main/groovy/au/org/ala/phyloviz/WidgetInterface.groovy diff --git a/src/main/groovy/WidgetType.groovy b/src/main/groovy/au/org/ala/phyloviz/WidgetType.groovy similarity index 100% rename from src/main/groovy/WidgetType.groovy rename to src/main/groovy/au/org/ala/phyloviz/WidgetType.groovy diff --git a/src/main/groovy/LayerDefinition.groovy b/src/main/groovy/au/org/ala/soils2sat/LayerDefinition.groovy similarity index 100% rename from src/main/groovy/LayerDefinition.groovy rename to src/main/groovy/au/org/ala/soils2sat/LayerDefinition.groovy diff --git a/src/main/groovy/LayerTreeNode.groovy b/src/main/groovy/au/org/ala/soils2sat/LayerTreeNode.groovy similarity index 100% rename from src/main/groovy/LayerTreeNode.groovy rename to src/main/groovy/au/org/ala/soils2sat/LayerTreeNode.groovy From 30cdcdd58aa4ea9b490f6db032f3d72406ab01bc Mon Sep 17 00:00:00 2001 From: jasensch Date: Wed, 30 Oct 2019 20:52:20 +1100 Subject: [PATCH 2/3] Issue 209: Ability to save visualisation as package and mint DOI for package (https://github.com/AtlasOfLivingAustralia/phylolink/issues/209); Issue 210: Ability to list 'expert/DOI' visualisation packages (via visualisation name) and allow user to select one to display (use) (https://github.com/AtlasOfLivingAustralia/phylolink/issues/210); * More user friendly message shown to an unauthenticated user trying to access a visualisation (that is not a demonstration visualisation), as apart from demonstration visualisations, to view a visualisation the user must now be authenticated. * added a "Metadata" tab to capture the fields to create an expert visualisation that will form a permanent digital object identifier (doi) reference (not shown for demonstration visualisations) * the Metadata tab requires an occurrence data set to be selected before a doi visualisation can be created. A Character data set is optional. * parse the Species names from the tree and show on the metadata tab, prepopulate the Genus field from the first word (based on the first space or underscore) in the Species names. * On the Characters tab, and at the point in the existing application when characters are associated with the visualisation, show a characters section on the Metadata tab. This characters section will have 2 mandatory fields for each trait: description and classification (one of: continuous, integer, discrete, or categorical) * On the metadata tab allow the Character trait metadata to be loaded from a csv as an alternative to doing the data entry manually * Allow the saving of data on the metadata tab, without having all mandatory information provided. However, when a workflow action is attempted, all mandatory information is still validated that it has been provided. * added a workflow (see /documentation/visualisation_workflow_status_diagram) to allow the approval and feedback on visualisations. During the workflow, all workflow administrators ( currently identified by having the PHYLOLINK_ADMIN role) are emailed when an author requests approval (they are also sent a For your information email when the visualisation is handled by someone else). The visualisation author is emailed when the visualisation is "Approve and Publish" or "Revision Required" by a Workflow Admin. * Prevent the removal of data sets (occurrences and character traits) that are associated with a visualisation that is Published. * Prevent the editing of tree metadata by a phylolink admin, if the tree is associated with a Published visualisation * On the characters tab fixed current production behaviour when a file is uploaded that has no species that overlap with the tree, as the uploaded file is set to be the selected characters, but on reload any characters that do not overlap with the tree are filtered out, so the previously selected characters is changed to the first characters file available, which wasn't selected by the user. * On the characters tab fixed current production behaviour, when the current character file is removed another characters file would be selected if there was one in the dropdown , (both on the screen and when reloaded), now the Choose option is selected * When adding a character to the tree (on the Characters tab), the character metadata description is shown, if it exists * On the Start Phylolink page, alert if there are any visualisations that require the attention of the logged on user (which are those in status of "Revision Required" for authors and "Requested Approval" for Workflow Admins, with a link to the list of the visualisations * On the Start Phylolink page, add an option to view Published visualisations in reverse chronological order that they were published * On the Published visualisations page, implement the text phrase search filter (where each word (limited to the first 5) in the search phrase must exist (case insensitive) in at least one of the following visualisation fields: title, genus, species, created by, doi) * Moved sample csv files from src/main/webapp to src/main/resources/public so they are packaged in the JAR (see https://gsp.grails.org/latest/guide/resources.html) that is run in non-development environments, to fix the 404 errors currently in production; * Updated README.md for receiving emails in local environments, change nameindexes to match production which is 20171012, fix for web2py restart issues in the vagrant container; * New parameters in /data/phylolink/config/phylolink-config.properties (ones that are commented out show their default values): alaDoiUrl = https://doi-test.ala.org.au alaDoiUrl_webservice_apiKey = secret_key_value #doi.author = Atlas Of Living Australia #doi.provider = ANDS #doi.description = ALA phylolink visualisation doi.titlePrefix = Phylolink visualisation - #doi.citationUrlPrefix = https://doi.org/ doi.licences.1 = Creative Commons Attribution (Australia) (CC-BY 3.0 (Au)) doi.licences.2 = Creative Commons Attribution (International) (CC-BY 4.0 (Int)) --- .gitignore | 4 + README.md | 24 +- build.gradle | 3 +- ...sualisation_workflow_status_diagram.gliffy | 1 + .../visualisation_workflow_status_diagram.png | Bin 0 -> 29010 bytes grails-app/assets/javascripts/js/Character.js | 157 +++++-- grails-app/assets/javascripts/js/Expert.js | 362 ++++++++++++++++ grails-app/assets/javascripts/js/PJ.js | 8 +- grails-app/assets/javascripts/js/PhyloLink.js | 2 +- grails-app/assets/javascripts/js/Records.js | 36 +- .../assets/javascripts/js/application.js | 10 +- .../javascripts/thirdparty/papaparse.min.js | 7 + grails-app/assets/stylesheets/phylolink.css | 27 +- grails-app/conf/application.yml | 22 + grails-app/controllers/UrlMappings.groovy | 2 +- .../au/org/ala/phyloviz/BaseController.groovy | 32 +- .../ala/phyloviz/CharactersController.groovy | 62 ++- .../org/ala/phyloviz/PhyloController.groovy | 145 +++++-- .../org/ala/phyloviz/SandboxController.groovy | 9 +- .../au/org/ala/phyloviz/TreeController.groovy | 18 +- .../org/ala/phyloviz/WizardController.groovy | 29 +- .../au/org/ala/phyloviz/CharacterTrait.groovy | 71 ++++ .../ala/phyloviz/CharacterTraitImage.groovy | 21 + .../au/org/ala/phyloviz/Characters.groovy | 10 + .../domain/au/org/ala/phyloviz/Phylo.groovy | 121 +++++- .../domain/au/org/ala/phyloviz/Sandbox.groovy | 3 + .../au/org/ala/phyloviz/Visualization.groovy | 2 + .../ala/phyloviz/WorkflowStatusEntry.groovy | 39 ++ .../org/ala/phyloviz/CharactersService.groovy | 18 +- .../au/org/ala/phyloviz/DoiService.groovy | 122 ++++++ .../au/org/ala/phyloviz/EmailService.groovy | 27 ++ .../au/org/ala/phyloviz/PhyloService.groovy | 389 +++++++++++++++++- .../ala/phyloviz/SpeciesListService.groovy | 3 +- .../au/org/ala/phyloviz/UserService.groovy | 45 +- .../org/ala/phyloviz/WebServiceService.groovy | 5 +- grails-app/views/phylo/_character.gsp | 20 +- grails-app/views/phylo/_doi.gsp | 292 +++++++++++++ grails-app/views/phylo/_expert.gsp | 305 ++++++++++++++ grails-app/views/phylo/_metadata.gsp | 18 +- grails-app/views/phylo/_occurrence.gsp | 12 +- grails-app/views/phylo/_title.gsp | 4 +- grails-app/views/phylo/emails/doi-failure.gsp | 8 + .../emails/status-change-to-approver.gsp | 13 + .../phylo/emails/status-change-to-creator.gsp | 13 + grails-app/views/phylo/show.gsp | 104 ++++- grails-app/views/wizard/pick.gsp | 46 ++- grails-app/views/wizard/published.gsp | 110 +++++ .../views/wizard/vizThatNeedMyAttention.gsp | 51 +++ .../au/org/ala/phyloviz/ConvertSandbox.groovy | 3 +- .../public}/artifacts/occurrenceRecords.csv | 0 .../public}/artifacts/traits.csv | 0 .../public/artifacts/traits_metadata.csv | 26 ++ .../au/org/ala/phyloviz/PhyloTest.groovy | 27 ++ 53 files changed, 2736 insertions(+), 152 deletions(-) create mode 100644 documentation/visualisation_workflow_status_diagram.gliffy create mode 100644 documentation/visualisation_workflow_status_diagram.png create mode 100644 grails-app/assets/javascripts/js/Expert.js create mode 100644 grails-app/assets/javascripts/thirdparty/papaparse.min.js create mode 100644 grails-app/domain/au/org/ala/phyloviz/CharacterTrait.groovy create mode 100644 grails-app/domain/au/org/ala/phyloviz/CharacterTraitImage.groovy create mode 100644 grails-app/domain/au/org/ala/phyloviz/WorkflowStatusEntry.groovy create mode 100644 grails-app/services/au/org/ala/phyloviz/DoiService.groovy create mode 100644 grails-app/services/au/org/ala/phyloviz/EmailService.groovy create mode 100644 grails-app/views/phylo/_doi.gsp create mode 100644 grails-app/views/phylo/_expert.gsp create mode 100644 grails-app/views/phylo/emails/doi-failure.gsp create mode 100644 grails-app/views/phylo/emails/status-change-to-approver.gsp create mode 100644 grails-app/views/phylo/emails/status-change-to-creator.gsp create mode 100644 grails-app/views/wizard/published.gsp create mode 100644 grails-app/views/wizard/vizThatNeedMyAttention.gsp rename src/main/{webapp => resources/public}/artifacts/occurrenceRecords.csv (100%) rename src/main/{webapp => resources/public}/artifacts/traits.csv (100%) create mode 100644 src/main/resources/public/artifacts/traits_metadata.csv create mode 100644 src/test/groovy/au/org/ala/phyloviz/PhyloTest.groovy diff --git a/.gitignore b/.gitignore index 4c427f2a..1a5fc1b6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ build target/ *.iml .idea/* +/.settings +bin +/.classpath +/.project diff --git a/README.md b/README.md index 8753db5e..d7d2400c 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,19 @@ $ sudo systemctl start phylolink $ sudo systemctl status phylolink ``` +After halting and starting the vagrant container you may find that web2py doesn't start, resulting in the inability to add a new phylogenetic tree. To fix this: +``` +$ sudo vim /etc/init.d/web2py + In the do_start() function, comment out the following 2 lines by inserting # at the start of the lines + start-stop-daemon --stop --test --quiet --pidfile $PIDFILE \ + && return 1 + save and exit vi (:x) +$ sudo systemctl daemon-reload +$ sudo systemctl start web2py +$ sudo systemctl status web2py + Should report that the service is "active (running)" +``` + ### Connecting to the phyolink postgres db Install a postgres client on your machine e.g. https://www.pgadmin.org/ @@ -111,6 +124,11 @@ View http://phylolink.vagrant1.ala.org.au in a browser. For the standard ansibl $ sudo systemctl status phylolink ``` +You can now exit from the vagrant container secure shell with the command: +``` +$ exit +``` + ### Running phylolink from an IDE for rapid development * Clone phylolink from GitHub e.g. @@ -126,7 +144,7 @@ $ git clone https://github.com/AtlasOfLivingAustralia/phylolink.git * Create the following directory structure: ```/data/phylolink/config``` * Copy all files in ~/ala-install/ansible/roles/phylolink/templates to /data/phylolink/config and substitute the variable values (speak to someone from the development team) * Install the name index by - * Download the latest names index file from http://biocache.ala.org.au/archives/nameindexes/ (At the time of writing the latest one was 20190213/namematching-20190213.tgz) + * Download the version of the names index as specified in the nameindex_datestamp property in ~/ala-install/ansible/inventories/vagrant/phylolink-vagrantfile from https://archives.ala.org.au/archives/nameindexes (At the time of writing the value was 20171012-lucene5 so the URL to download from is https://archives.ala.org.au/archives/nameindexes/20171012-lucene5/namematching-20171012-lucene5.tgz) * Unzip it to /data/lucene/namematching so that you end up with the cb, id, irmng and vernacular sub folders * NOTE: the name.index.location property in /data/phylolink/config/phylolink-config.properties must match the /data/lucene/namematching folder name * Add a hosts file entry: 127.0.0.1 devt.ala.org.au @@ -135,6 +153,10 @@ $ git clone https://github.com/AtlasOfLivingAustralia/phylolink.git $ cd ~/phylolink $ grails run-app -port=8090 ``` +* To be able to view the emails sent during the approval workflow, you will have a local mail server. The config.groovy is set up to point at a mail server on port 1025. https://nilhcem.github.io/FakeSMTP/download.html is a good option, which can be run with the following command in a different terminal to terminal where you are running grails: +``` +java -jar ~/fakeSMTP-2.0.jar --start-server --port 1025 +``` * View http://devt.ala.org.au:8090/phylolink in a browser to test the application which is running the grails part of the application locally and consuming the web services and postgres db running in the vagrant container. ## Installing on production diff --git a/build.gradle b/build.gradle index 96378f40..91ed3a1a 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,7 @@ dependencies { compile "org.grails.plugins:events" compile "org.grails.plugins:hibernate5" compile 'org.grails.plugins:external-config:1.2.2' + compile "org.grails.plugins:mail:2.0.0.RC6" compile "org.hibernate:hibernate-core:5.1.5.Final" compile "org.grails.plugins:gsp" console "org.grails:grails-console" @@ -81,7 +82,7 @@ dependencies { compile group: 'org.grails.plugins', name: 'ala-bootstrap3', version: '3.2.0', changing: true compile group: 'org.grails.plugins', name: 'ala-admin-plugin', version: '2.1', changing: true - + runtime "javax.mail:mail:1.4.7" compile group: 'au.org.ala', name: 'ala-name-matching', version: '3.3', changing: true diff --git a/documentation/visualisation_workflow_status_diagram.gliffy b/documentation/visualisation_workflow_status_diagram.gliffy new file mode 100644 index 00000000..bc7828cb --- /dev/null +++ b/documentation/visualisation_workflow_status_diagram.gliffy @@ -0,0 +1 @@ +{"contentType":"application/gliffy+json","version":"1.1","metadata":{"title":"untitled","revision":0,"exportBorder":false},"embeddedResources":{"index":0,"resources":[]},"stage":{"objects":[{"x":465,"y":27,"rotation":0,"id":42,"uid":"com.gliffy.shape.basic.basic_v1.default.text","width":150,"height":14,"lockAspectRatio":false,"lockShape":false,"order":27,"graphic":{"type":"Text","Text":{"tid":null,"valign":"middle","overflow":"none","vposition":"none","hposition":"none","html":"

Version 1.1 (11/10/2019)

","paddingLeft":2,"paddingRight":2,"paddingBottom":2,"paddingTop":2}},"children":null,"linkMap":[]},{"x":16,"y":14,"rotation":0,"id":40,"uid":"com.gliffy.shape.basic.basic_v1.default.text","width":366.00000000000006,"height":40,"lockAspectRatio":false,"lockShape":false,"order":26,"graphic":{"type":"Text","Text":{"tid":null,"valign":"middle","overflow":"none","vposition":"none","hposition":"none","html":"

Phylolink Visualisation Workflow Diagram

","paddingLeft":2,"paddingRight":2,"paddingBottom":2,"paddingTop":2}},"children":null,"linkMap":[]},{"x":414,"y":206,"rotation":0,"id":36,"uid":"com.gliffy.shape.basic.basic_v1.default.text","width":234,"height":54,"lockAspectRatio":false,"lockShape":false,"order":24,"graphic":{"type":"Text","Text":{"tid":null,"valign":"middle","overflow":"none","vposition":"none","hposition":"none","html":"

Status changes that can be performed\n

by the creator of the visualisation

","paddingLeft":2,"paddingRight":2,"paddingBottom":2,"paddingTop":2}},"children":null,"linkMap":[]},{"x":282,"y":296,"rotation":0,"id":34,"uid":"com.gliffy.shape.uml.uml_v1.default.message","width":100,"height":100,"lockAspectRatio":false,"lockShape":false,"order":22,"graphic":{"type":"Line","Line":{"strokeWidth":1,"strokeColor":"#000000","fillColor":"none","dashStyle":null,"startArrow":0,"endArrow":2,"startArrowRotation":"auto","endArrowRotation":"auto","ortho":false,"interpolationType":"linear","cornerRadius":null,"controlPath":[[-2,1.5],[-112,1.5]],"lockSegments":{}}},"children":[{"x":0,"y":0,"rotation":0,"id":35,"uid":null,"width":56,"height":27,"lockAspectRatio":false,"lockShape":false,"order":"auto","graphic":{"type":"Text","Text":{"tid":null,"valign":"middle","overflow":"both","vposition":"none","hposition":"none","html":"

No Longer\n

Required

","paddingLeft":2,"paddingRight":2,"paddingBottom":2,"paddingTop":2}},"children":null}],"constraints":{"constraints":[],"startConstraint":{"type":"StartPositionConstraint","StartPositionConstraint":{"nodeId":9,"px":0,"py":0.5}},"endConstraint":{"type":"EndPositionConstraint","EndPositionConstraint":{"nodeId":12,"px":1,"py":0.5}}},"linkMap":[]},{"x":359,"y":145,"rotation":0,"id":32,"uid":"com.gliffy.shape.uml.uml_v1.default.message","width":100,"height":100,"lockAspectRatio":false,"lockShape":false,"order":20,"graphic":{"type":"Line","Line":{"strokeWidth":1,"strokeColor":"#000000","fillColor":"none","dashStyle":null,"startArrow":0,"endArrow":2,"startArrowRotation":"auto","endArrowRotation":"auto","ortho":false,"interpolationType":"linear","cornerRadius":null,"controlPath":[[8,1],[8,111.11357772772624]],"lockSegments":{}}},"children":[{"x":0,"y":0,"rotation":0,"id":33,"uid":null,"width":49,"height":27,"lockAspectRatio":false,"lockShape":false,"order":"auto","graphic":{"type":"Text","Text":{"tid":null,"valign":"middle","overflow":"both","vposition":"none","hposition":"none","html":"

Revision\n

Required

","paddingLeft":2,"paddingRight":2,"paddingBottom":2,"paddingTop":2}},"children":null}],"linkMap":[]},{"x":309,"y":259,"rotation":0,"id":30,"uid":"com.gliffy.shape.uml.uml_v1.default.message","width":100,"height":100,"lockAspectRatio":false,"lockShape":false,"order":18,"graphic":{"type":"Line","Line":{"strokeWidth":1,"strokeColor":"#000000","fillColor":"none","dashStyle":null,"startArrow":0,"endArrow":2,"startArrowRotation":"auto","endArrowRotation":"auto","ortho":false,"interpolationType":"linear","cornerRadius":null,"controlPath":[[-14.999999999999943,0.0173899895141858],[-15,-115]],"lockSegments":{}}},"children":[{"x":0,"y":0,"rotation":0,"id":31,"uid":null,"width":47,"height":27,"lockAspectRatio":false,"lockShape":false,"order":"auto","graphic":{"type":"Text","Text":{"tid":null,"valign":"middle","overflow":"both","vposition":"none","hposition":"none","html":"

Request\n

Approval

","paddingLeft":2,"paddingRight":2,"paddingBottom":2,"paddingTop":2}},"children":null}],"linkMap":[]},{"x":380,"y":105,"rotation":0,"id":26,"uid":"com.gliffy.shape.uml.uml_v1.default.message","width":100,"height":100,"lockAspectRatio":false,"lockShape":false,"order":16,"graphic":{"type":"Line","Line":{"strokeWidth":1,"strokeColor":"#000000","fillColor":"none","dashStyle":null,"startArrow":0,"endArrow":2,"startArrowRotation":"auto","endArrowRotation":"auto","ortho":false,"interpolationType":"linear","cornerRadius":null,"controlPath":[[0,2.5],[110,2.5]],"lockSegments":{}}},"children":[{"x":0,"y":0,"rotation":0,"id":27,"uid":null,"width":45,"height":41,"lockAspectRatio":false,"lockShape":false,"order":"auto","graphic":{"type":"Text","Text":{"tid":null,"valign":"middle","overflow":"both","vposition":"none","hposition":"none","html":"

Approve\n

and\n

Publish

","paddingLeft":2,"paddingRight":2,"paddingBottom":2,"paddingTop":2}},"children":null}],"constraints":{"constraints":[],"startConstraint":{"type":"StartPositionConstraint","StartPositionConstraint":{"nodeId":3,"px":1,"py":0.5}},"endConstraint":{"type":"EndPositionConstraint","EndPositionConstraint":{"nodeId":6,"px":0,"py":0.5}}},"linkMap":[]},{"x":142,"y":97,"rotation":0,"id":24,"uid":"com.gliffy.shape.uml.uml_v1.default.message","width":100,"height":100,"lockAspectRatio":false,"lockShape":false,"order":14,"graphic":{"type":"Line","Line":{"strokeWidth":1,"strokeColor":"#000000","fillColor":"none","dashStyle":null,"startArrow":0,"endArrow":2,"startArrowRotation":"auto","endArrowRotation":"auto","ortho":false,"interpolationType":"linear","cornerRadius":null,"controlPath":[[28,10.5],[138,10.5]],"lockSegments":{}}},"children":[{"x":0,"y":0,"rotation":0,"id":25,"uid":null,"width":47,"height":27,"lockAspectRatio":false,"lockShape":false,"order":"auto","graphic":{"type":"Text","Text":{"tid":null,"valign":"middle","overflow":"both","vposition":"none","hposition":"none","html":"

Request\n

Approval

","paddingLeft":2,"paddingRight":2,"paddingBottom":2,"paddingTop":2}},"children":null}],"constraints":{"constraints":[],"startConstraint":{"type":"StartPositionConstraint","StartPositionConstraint":{"nodeId":0,"px":1,"py":0.5}},"endConstraint":{"type":"EndPositionConstraint","EndPositionConstraint":{"nodeId":3,"px":0,"py":0.5}}},"linkMap":[]},{"x":119,"y":293,"rotation":0,"id":22,"uid":"com.gliffy.shape.uml.uml_v1.default.message","width":100,"height":100,"lockAspectRatio":false,"lockShape":false,"order":12,"graphic":{"type":"Line","Line":{"strokeWidth":1,"strokeColor":"#000000","fillColor":"none","dashStyle":null,"startArrow":0,"endArrow":2,"startArrowRotation":"auto","endArrowRotation":"auto","ortho":false,"interpolationType":"linear","cornerRadius":null,"controlPath":[[-38.99999999999997,-35.92795131087212],[-39,-147]],"lockSegments":{}}},"children":[{"x":0,"y":0,"rotation":0,"id":23,"uid":null,"width":50,"height":14,"lockAspectRatio":false,"lockShape":false,"order":"auto","graphic":{"type":"Text","Text":{"tid":null,"valign":"middle","overflow":"both","vposition":"none","hposition":"none","html":"

Reinstate

","paddingLeft":2,"paddingRight":2,"paddingBottom":2,"paddingTop":2}},"children":null}],"linkMap":[]},{"x":130,"y":145,"rotation":0,"id":15,"uid":"com.gliffy.shape.uml.uml_v1.default.message","width":100,"height":100,"lockAspectRatio":false,"lockShape":false,"order":10,"graphic":{"type":"Line","Line":{"strokeWidth":1,"strokeColor":"#000000","fillColor":"none","dashStyle":null,"startArrow":0,"endArrow":2,"startArrowRotation":"auto","endArrowRotation":"auto","ortho":false,"interpolationType":"linear","cornerRadius":null,"controlPath":[[24,0],[24.000000000000057,113.07077429645557]],"lockSegments":{}}},"children":[{"x":0,"y":0,"rotation":0,"id":21,"uid":null,"width":56,"height":27,"lockAspectRatio":false,"lockShape":false,"order":"auto","graphic":{"type":"Text","Text":{"tid":null,"valign":"middle","overflow":"both","vposition":"none","hposition":"none","html":"

No Longer\n

Required

","paddingLeft":2,"paddingRight":2,"paddingBottom":2,"paddingTop":2}},"children":null}],"linkMap":[]},{"x":70,"y":260,"rotation":0,"id":12,"uid":"com.gliffy.shape.flowchart.flowchart_v1.default.process","width":100,"height":75,"lockAspectRatio":false,"lockShape":false,"order":8,"graphic":{"type":"Shape","Shape":{"tid":"com.gliffy.stencil.rectangle.basic_v1","strokeWidth":2,"strokeColor":"#333333","fillColor":"#FFFFFF","gradient":false,"dropShadow":false,"state":0,"shadowX":0,"shadowY":0,"opacity":1}},"children":[{"x":2,"y":0,"rotation":0,"id":14,"uid":null,"width":96,"height":27,"lockAspectRatio":false,"lockShape":false,"order":"auto","graphic":{"type":"Text","Text":{"tid":null,"valign":"middle","overflow":"none","vposition":"none","hposition":"none","html":"

No Longer Required

","paddingLeft":2,"paddingRight":2,"paddingBottom":2,"paddingTop":2}},"children":null}],"linkMap":[]},{"x":280,"y":260,"rotation":0,"id":9,"uid":"com.gliffy.shape.flowchart.flowchart_v1.default.process","width":100,"height":75,"lockAspectRatio":false,"lockShape":false,"order":6,"graphic":{"type":"Shape","Shape":{"tid":"com.gliffy.stencil.rectangle.basic_v1","strokeWidth":2,"strokeColor":"#333333","fillColor":"#FFFFFF","gradient":false,"dropShadow":false,"state":0,"shadowX":0,"shadowY":0,"opacity":1}},"children":[{"x":2,"y":0,"rotation":0,"id":11,"uid":null,"width":96,"height":27,"lockAspectRatio":false,"lockShape":false,"order":"auto","graphic":{"type":"Text","Text":{"tid":null,"valign":"middle","overflow":"none","vposition":"none","hposition":"none","html":"

Revision Required

","paddingLeft":2,"paddingRight":2,"paddingBottom":2,"paddingTop":2}},"children":null}],"linkMap":[]},{"x":490,"y":70,"rotation":0,"id":6,"uid":"com.gliffy.shape.flowchart.flowchart_v1.default.process","width":100,"height":75,"lockAspectRatio":false,"lockShape":false,"order":4,"graphic":{"type":"Shape","Shape":{"tid":"com.gliffy.stencil.rectangle.basic_v1","strokeWidth":2,"strokeColor":"#333333","fillColor":"#FFFFFF","gradient":false,"dropShadow":false,"state":0,"shadowX":0,"shadowY":0,"opacity":1}},"children":[{"x":2,"y":0,"rotation":0,"id":8,"uid":null,"width":96,"height":14,"lockAspectRatio":false,"lockShape":false,"order":"auto","graphic":{"type":"Text","Text":{"tid":null,"valign":"middle","overflow":"none","vposition":"none","hposition":"none","html":"

Published

","paddingLeft":2,"paddingRight":2,"paddingBottom":2,"paddingTop":2}},"children":null}],"linkMap":[]},{"x":280,"y":70,"rotation":0,"id":3,"uid":"com.gliffy.shape.flowchart.flowchart_v1.default.process","width":100,"height":75,"lockAspectRatio":false,"lockShape":false,"order":2,"graphic":{"type":"Shape","Shape":{"tid":"com.gliffy.stencil.rectangle.basic_v1","strokeWidth":2,"strokeColor":"#333333","fillColor":"#FFFFFF","gradient":false,"dropShadow":false,"state":0,"shadowX":0,"shadowY":0,"opacity":1}},"children":[{"x":2,"y":0,"rotation":0,"id":5,"uid":null,"width":96,"height":27,"lockAspectRatio":false,"lockShape":false,"order":"auto","graphic":{"type":"Text","Text":{"tid":null,"valign":"middle","overflow":"none","vposition":"none","hposition":"none","html":"

Requested Approval

","paddingLeft":2,"paddingRight":2,"paddingBottom":2,"paddingTop":2}},"children":null}],"linkMap":[]},{"x":70,"y":70,"rotation":0,"id":0,"uid":"com.gliffy.shape.flowchart.flowchart_v1.default.process","width":100,"height":75,"lockAspectRatio":false,"lockShape":false,"order":0,"graphic":{"type":"Shape","Shape":{"tid":"com.gliffy.stencil.rectangle.basic_v1","strokeWidth":2,"strokeColor":"#333333","fillColor":"#FFFFFF","gradient":false,"dropShadow":false,"state":0,"shadowX":0,"shadowY":0,"opacity":1}},"children":[{"x":2,"y":0,"rotation":0,"id":2,"uid":null,"width":96,"height":14,"lockAspectRatio":false,"lockShape":false,"order":"auto","graphic":{"type":"Text","Text":{"tid":null,"valign":"middle","overflow":"none","vposition":"none","hposition":"none","html":"

Draft

","paddingLeft":2,"paddingRight":2,"paddingBottom":2,"paddingTop":2}},"children":null}],"linkMap":[]},{"x":414,"y":257,"rotation":0,"id":39,"uid":"com.gliffy.shape.basic.basic_v1.default.text","width":234,"height":27,"lockAspectRatio":false,"lockShape":false,"order":25,"graphic":{"type":"Text","Text":{"tid":null,"valign":"middle","overflow":"none","vposition":"none","hposition":"none","html":"

Status changes that can be performed\n

by a Phylolink Administrator

","paddingLeft":2,"paddingRight":2,"paddingBottom":2,"paddingTop":2}},"children":null,"linkMap":[]},{"x":412,"y":294,"rotation":0,"id":46,"uid":"com.gliffy.shape.basic.basic_v1.default.text","width":234,"height":41,"lockAspectRatio":false,"lockShape":false,"order":28,"graphic":{"type":"Text","Text":{"tid":null,"valign":"middle","overflow":"none","vposition":"none","hposition":"none","html":"

Status changes that can be performed\n

by the creator of the visualisation\n

or a Phylolink Administrator

","paddingLeft":2,"paddingRight":2,"paddingBottom":2,"paddingTop":2}},"children":null,"linkMap":[]}],"background":"#FFFFFF","width":646,"height":335,"maxWidth":5000,"maxHeight":5000,"nodeIndex":47,"autoFit":true,"exportBorder":false,"gridOn":true,"snapToGrid":true,"drawingGuidesOn":true,"pageBreaksOn":false,"printGridOn":false,"printPaper":"LETTER","printShrinkToFit":false,"printPortrait":true,"shapeStyles":{"com.gliffy.shape.flowchart.flowchart_v1.default":{"fill":"#FFFFFF","stroke":"#333333","strokeWidth":2},"com.gliffy.shape.basic.basic_v1.default":{"fill":"#FFFFFF","stroke":"#333333","strokeWidth":2}},"lineStyles":{"global":{}},"textStyles":{},"themeData":null}} \ No newline at end of file diff --git a/documentation/visualisation_workflow_status_diagram.png b/documentation/visualisation_workflow_status_diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..d35fb8693c3f1f9372863830fc16a77251fbb09f GIT binary patch literal 29010 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 literal 0 HcmV?d00001 diff --git a/grails-app/assets/javascripts/js/Character.js b/grails-app/assets/javascripts/js/Character.js index 56567330..02d471ba 100644 --- a/grails-app/assets/javascripts/js/Character.js +++ b/grails-app/assets/javascripts/js/Character.js @@ -120,6 +120,8 @@ var Character = function (options) { var Character = function (opt) { this.name = ko.observable(opt.name); this.id = ko.observable(opt.id); + this.description = ko.observable([]); + this.type = ko.observable([]); } var CharacterViewModel = function () { @@ -143,9 +145,10 @@ var Character = function (options) { self.lists = ko.observableArray([]); self.activeCharacterList = ko.observableArray([]); self.list = ko.observable({}); - self.edit = ko.observable(options.edit); + self.edit = ko.observable(); self.listLoading = ko.observable(false); - + self.saveSource = false; + self.clearCharacter = function (data, event) { if (data === self.selectedCharacter()) { self.selectedCharacter(null); @@ -270,7 +273,23 @@ var Character = function (options) { } self.loadNewCharacters = function(){ - self.activeCharacterList.removeAll(); + var sel = self.list(); + if (self.saveSource && self.edit()) { + var characterSourceId = (sel == null ? null : sel.id); + var sync = {id: options.phyloId, characterSourceId: characterSourceId}; + $.ajax({ + url: options.syncSourceUrl, + data: sync, + success: function (data) { + console.log('saved charactersSource in database id=' + options.phyloId + ', characterSourceId=' + characterSourceId + ' !'); + }, + error: function () { + console.log('error saving charactersSource id=' + options.phyloId + ', characterSourceId=' + characterSourceId + ' !'); + } + }); + } + + self.activeCharacterList.removeAll(); if(self.list()){ self.characters.removeAll() that.loadCharacterFromUrl(self.list().url); @@ -290,11 +309,15 @@ var Character = function (options) { */ self.removeSource = function(item){ + //the remove below can change self.list() so look it up before the remove + var removeCurrentSelection = (self.list() != null && self.list().id == item.id); + + self.saveSource = false; self.lists.remove( function (listToTest) { return listToTest.id == item.id; } ); + self.saveSource = true; - //is the source the active source - if(self.list.id == item.id){ - self.list({}); + if(removeCurrentSelection){ + self.list(null); self.activeCharacterList([]); } @@ -352,7 +375,9 @@ var Character = function (options) { upload.headers([]); upload.charactersTitle(''); upload.selectedValue(''); - $('#characterUploadPanel').attr('aria-expanded', false).removeClass('in'); + if (!upload.message()) { + $('#characterUploadPanel').attr('aria-expanded', false).removeClass('in'); + } } /** @@ -372,6 +397,12 @@ var Character = function (options) { } } return selected; + } + + self.findResourceById = function(id){ + return ko.utils.arrayFirst(self.lists(), function(item){ + return id === item.id; + }); } }; @@ -958,10 +989,43 @@ var Character = function (options) { data: data, processData: false, contentType: false, - success: function(data){ - spinner.stop(); - view.addNewSource(data); - view.resetForm(); + success: function(result){ + // check if the just uploaded characters files does not have any species that overlap with the species in the tree + $.ajax({ + url: options.checkCharacterFile.url, + dataType:'JSON', + data: { + id: result.id, + treeId: options.treeId + }, + success: function(data){ + spinner.stop(); + if (data.isCompatibleWithTree) { + result = $.extend({ + canDelete: true, + }, result); + view.addNewSource(result); + } else { + //show message that file is not compatible + $(upload.alertId).alert(); + upload.message('There are no species in your uploaded file that match the tree. Please ensure the species scientific name ' + + 'exactly matches the species name as shown in the tree (please note that underscores in the species names in the tree ' + + 'are replaced with spaces before being shown, so please remove underscores from your species names in your character ' + + 'data). Please update the file or choose another file.'); +// setTimeout($(upload.alertId).close, 10000) + console.log('There are no species in your uploaded file that match the tree. Please update the file or choose another file.') + } + view.resetForm(); + }, + error: function(){ + spinner.stop(); + $(upload.alertId).alert(); + upload.message('Checking that the uploaded file is compatible with the tree failed'); + setTimeout($(upload.alertId).close, 5000) + console.log('Checking that the uploaded file is compatible with the tree failed!') + view.resetForm(); + } + }) }, error: function(){ spinner.stop(); @@ -1020,6 +1084,9 @@ var Character = function (options) { * load character from url or from provided list. */ if(options.initCharacters && options.initCharacters.list){ + options.initCharacters.list.id = null; + options.initCharacters.list.title = '(Removed while active) ' + options.initCharacters.list.title; + options.initCharacters.list.canDelete = false; view.addNewSource(options.initCharacters.list); }else if (options.url) { $.ajax({ @@ -1041,7 +1108,8 @@ var Character = function (options) { url: options.charactersList.url, dataType:'JSON', data: { - treeId: options.treeId + treeId: options.treeId, + initCharacterResourceId: options.initCharacterResourceId }, success: function(data){ var i, slistId; @@ -1052,10 +1120,28 @@ var Character = function (options) { } } flag = true + character.selectADataresource(); } }) } + /** + * select a data resource on init + * todo: change this + */ + this.selectADataresource = function(){ + var id = options.initCharacterResourceId, + selected = view.list(); + if(id || !selected){ + if(options.selectResourceOnInit){ + var dr = view.findResourceById(id); + view.list(dr); + view.loadNewCharacters(); + view.saveSource = true; + } + } + } + function initPopover(){ var pops = options.popOver, i,id; if($.cookie('_chari') == "ok") { @@ -1232,22 +1318,33 @@ var Character = function (options) { // do not save when initializing the charts. changed event is fired there too. !init && that.emit('sync'); }); - if (options.edit){ - $("#csvFile").on('change', function(event){ - var file = event.target.files[0]; - that.readFile(file, that.showHeaders); - that.initialiseTitleAndSelected(file.name) - $('#sciNameColumn').focus(); - }); - - $("#uploadBtn").on('click', function(){ - that.uploadCharacter(); - return false; - }); - - $("#csvFormUnavailable").hide(); - } else { - $("#csvForm").hide(); - $('#charactermain .alert').hide(); - } + + view.edit.subscribe(function(newValue) { + if (newValue){ + $("#csvFile").on('change', function(event){ + var file = event.target.files[0]; + that.readFile(file, that.showHeaders); + that.initialiseTitleAndSelected(file.name) + $('#sciNameColumn').focus(); + }); + + $("#uploadBtn").on('click', function(){ + that.uploadCharacter(); + return false; + }); + + $("#csvForm").show(); + $('#charactermain .alert').show(); + $("#csvFormUnavailable").hide(); + } else { + $("#csvForm").hide(); + $('#charactermain .alert').hide(); + $("#csvFormUnavailable").show(); + } + }); + view.edit(options.edit); + + //Expose this for use by other tabs + this.config = options; + this.characterViewModel = view; }; \ No newline at end of file diff --git a/grails-app/assets/javascripts/js/Expert.js b/grails-app/assets/javascripts/js/Expert.js new file mode 100644 index 00000000..99549358 --- /dev/null +++ b/grails-app/assets/javascripts/js/Expert.js @@ -0,0 +1,362 @@ +/** + * The _expert.gsp file contains the view associated with this script. This code is used by the Expert tab in the UI. + */ +var Expert = function (opt) { + // emitter mixin. adding functions that support events. + new Emitter(this); + var $ = jQuery; + var options = opt; + var pj = options.pj; + var titleViewModel = options.pj.titleViewModel; + var tabDivId = 'manageExpert'; + + var ExpertViewModel = function () { + new Emitter(this); + var self = this; + var spinner = new Spinner({ + top: '50%', + left: '50%', + className: 'loader' + }); + + self.title = ko.observable(titleViewModel.title()); + self.genus = ko.observable(options.genus || ""); + self.species = ko.observable(options.species || ""); + self.authors = ko.observable(options.authors || ""); + self.publishedPapers = ko.observable(options.publishedPapers || ""); + self.unpublishedData = ko.observable(options.unpublishedData || false); + self.doi = ko.observable(options.doi || ""); + self.doiURL = ko.observable(options.doiURL || ""); + self.notes = ko.observable(options.notes || ""); + self.status = ko.observable(options.status || ""); + self.workflowComment = ko.observable(""); + self.doiCreationDate = ko.observable(options.doiCreationDate); + self.userCanApprove = ko.observable(options.userCanApprove); + self.userCanReject = ko.observable(options.userCanReject); + self.userCanReinstate = ko.observable(options.userCanReinstate); + self.edit = ko.observable(options.edit); + + self.formDisabled = function() { + return ! self.edit(); + }; + + self.successMessage = ko.observable(""); + self.errorMessage = ko.observable(""); + + self.sourceId = ko.observable(options.records.config.initResourceId); + self.sourceDisplayTitle = ko.observable(""); + self.biocacheQueryUrl = ko.observable(""); + + self.haveOccurrences = ko.computed(function() { + var sourceId = this.sourceId(); + return sourceId != null && sourceId != "" && sourceId != options.records.config.allOccurrencesSourceId; + }, this); + + self.characterDataSetId = ko.observable(options.character.config.initCharacterResourceId); + self.characterDataSetTitle = ko.observable(""); + self.characterListUrl = ko.observable(""); + self.characterDataSetTraits = ko.observableArray([]); // see convertCharacterDataSetTraits() below which converts options.characterDataSetTraits + self.characterDataSetTraitTypes = ko.observableArray(options.characterDataSetTraitTypes); + self.characterDataSetTraitImages = ko.observableArray([]); + self.sampleCSV = options.sampleCSV; + + self.characterMetadataSuccessMessage = ko.observable(""); + self.characterMetadataErrorMessage = ko.observable(""); + + self.haveCharacters = ko.computed(function() { + var characterDataSetId = this.characterDataSetId(); + return characterDataSetId != null && characterDataSetId != "" && characterDataSetId.toString() != "0"; + }, this); + + self.doiCreationDateFormatted = ko.computed(function() { + return dateToString(this.doiCreationDate()); + }, this); + + self.doiCitationURL = ko.computed(function() { + var doi = this.doi(); + if (doi == null || doi == "") { + return null; + } + return (options.doiCitationUrlPrefix + doi); + }, this); + + self.save = function() { + self.successMessage(null); + self.errorMessage(null); + + // Perform HTML5 validation on the form + // Allow partial save so don't perform validation, instead changeStatus will do the validation +// if (! document.getElementById(tabDivId).getElementsByTagName("FORM")[0].reportValidity()) { + // Validation failed so return +// return; +// } + + titleViewModel.title(self.title()); + + self.startSaving(); + + var syncData = { + id: options.phyloId, + newTitle: self.title(), + newGenus: self.genus(), + newAuthors: self.authors(), + newPublishedPapers: self.publishedPapers(), + newUnpublishedData: self.unpublishedData(), + newNotes: self.notes(), + newcharacterDataSetTraits: ko.toJSON(self.characterDataSetTraits) + }; + + $.ajax({ + url: options.syncUrl, + dataType: 'JSON', + type: "PUT", + data: syncData, + success: function (data) { + self.successMessage(data.message); + self.errorMessage(null); + self.stopSaving(); + }, + error: function (xhr, status, error){ + self.successMessage(null); + self.errorMessage('Failed to save. Error message = ' + xhr.responseJSON.error); + self.stopSaving(); + } + }); + }; + + self.changeStatus = function(newStatus) { + self.successMessage(null); + self.errorMessage(null); + + document.getElementById("workflowComment").required = (newStatus == options.revisionRequiredStatus); + + // Perform HTML5 validation on the form + if (! document.getElementById(tabDivId).getElementsByTagName("FORM")[0].reportValidity()) { + // Validation failed so return + return; + } + + titleViewModel.title(self.title()); + + self.startSaving(); + + var syncData = { + id: options.phyloId, + newTitle: self.title(), + newGenus: self.genus(), + newAuthors: self.authors(), + newPublishedPapers: self.publishedPapers(), + newUnpublishedData: self.unpublishedData(), + newNotes: self.notes(), + newcharacterDataSetTraits: ko.toJSON(self.characterDataSetTraits), + newStatus: newStatus, + workflowComment: self.workflowComment() + }; + + $.ajax({ + url: options.changeStatusUrl, + dataType: 'JSON', + type: "PUT", + data: syncData, + success: function (data) { + self.successMessage(data.message); + self.errorMessage(null); + self.doi(data.doi); + self.doiURL(data.doiURL); + self.doiCreationDate(data.doiCreationDate); + self.edit(data.edit); + options.records.dataresourceViewModel.edit(data.edit); + options.character.characterViewModel.edit(data.edit); + titleViewModel.edit(data.edit); + self.userCanApprove(data.userCanApprove); + self.userCanReject(data.userCanReject); + self.userCanReinstate(data.userCanReinstate); + self.status(newStatus); + + $('#workflowStatusEntriesTBody').append('' + dateToString(data.workflowStatusEntryDate) + + '' + newStatus + + '' + options.userName + + '' + self.workflowComment() + ''); + + self.workflowComment(''); + + self.stopSaving(); + }, + error: function (xhr, status, error){ + self.successMessage(null); + self.errorMessage('Failed to change status. Error message = ' + xhr.responseJSON.error); + self.stopSaving(); + } + }); + }; + + self.startSaving = function () { + if (!spinner || !spinner.el) { + spinner = new Spinner({ + top: '50%', + left: '50%', + className: 'loader' + }); + } + spinner.spin(); + $('#expertButtons').append(spinner.el); + }; + + self.stopSaving = function () { + spinner.stop(); + }; + + self.requestApproval = function () { + self.changeStatus(options.requestedApprovalStatus); + }; + + self.approve = function () { + self.changeStatus(options.approvedStatus); + }; + + self.revisionRequired = function () { + self.changeStatus(options.revisionRequiredStatus); + }; + + self.noLongerRequired = function () { + self.changeStatus(options.noLongerRequiredStatus); + }; + + self.reinstate = function () { + self.changeStatus(options.draftStatus); + }; + }; + + var expertViewModel = new ExpertViewModel(); + convertCharacterDataSetTraits(); + ko.applyBindings(expertViewModel, document.getElementById(tabDivId)); + + //Subscribe to when the title is updated. See https://knockoutjs.com/documentation/observables.html + titleViewModel.title.subscribe(function(newValue) { + expertViewModel.title(newValue); + }); + + //Subscribe to when the occurrence dataset is updated + options.records.dataresourceViewModel.selectedValue.subscribe(function(newValue) { + expertViewModel.sourceId(newValue === undefined ? null : newValue.id()); + expertViewModel.sourceDisplayTitle(newValue === undefined ? null : newValue.displayTitle()); + expertViewModel.biocacheQueryUrl(newValue === undefined ? null : newValue.biocacheQueryUrl()); + }); + + //Subscribe to when the character dataset is updated + options.character.on('setcharacterlist', function() { + var newValue = options.character.characterViewModel.list(); + expertViewModel.characterDataSetId(newValue === undefined || newValue == null ? null : newValue.id); + expertViewModel.characterDataSetTitle(newValue === undefined || newValue == null ? null : newValue.title); + expertViewModel.characterListUrl(newValue === undefined || newValue == null ? null : newValue.listurl); + + var existingCharacterDataSetTraits = expertViewModel.characterDataSetTraits(); + expertViewModel.characterDataSetTraits([]); + traitTitles = options.character.characterViewModel.activeCharacterList(); + for(var i = 0; i < traitTitles.length; i++) { + var characterDataSetTrait = findMatchingCharacterDataSetTrait(traitTitles[i], existingCharacterDataSetTraits); + if (characterDataSetTrait == null) { + characterDataSetTrait = new CharacterDataSetTrait(traitTitles[i], "", null, null); + } + expertViewModel.characterDataSetTraits.push(characterDataSetTrait); + } + }); + + //Subscribe to when a new character is added to the tree + options.character.characterViewModel.on('newchar', function() { + var character = options.character.characterViewModel.selectedCharacter(); + var title = character.name(); + + var existingCharacterDataSetTraits = expertViewModel.characterDataSetTraits(); + var characterDataSetTrait = findMatchingCharacterDataSetTrait(traitTitles[i], existingCharacterDataSetTraits); + if (characterDataSetTrait != null) { + character.description(characterDataSetTrait.description()); + character.type(characterDataSetTrait.type()); + } + }); + + function convertCharacterDataSetTraits() { + var existingCharacterDataSetTraits = expertViewModel.characterDataSetTraits(); + expertViewModel.characterDataSetTraits([]); + for(var i = 0; i < options.characterDataSetTraits.length; i++) { + var initCharacterDataSetTrait = options.characterDataSetTraits[i]; + var characterDataSetTrait = findMatchingCharacterDataSetTrait(initCharacterDataSetTrait.title, existingCharacterDataSetTraits); + if (characterDataSetTrait == null) { + characterDataSetTrait = new CharacterDataSetTrait(initCharacterDataSetTrait.title, initCharacterDataSetTrait.description, + initCharacterDataSetTrait.type, initCharacterDataSetTrait.image); + } + expertViewModel.characterDataSetTraits.push(characterDataSetTrait); + } + } + + function CharacterDataSetTrait(title, description, type, imageFileName) { + this.title = ko.observable(title); + this.description = ko.observable(description); + this.type = ko.observable(type); + this.imageFileName = ko.observable(imageFileName); + } + + function findMatchingCharacterDataSetTrait(traitTitle, characterDataSetTraits) { + for(var i = 0; i < characterDataSetTraits.length; i++) { + if (traitTitle.toLocaleLowerCase() === characterDataSetTraits[i].title().toLocaleLowerCase()) { + return characterDataSetTraits[i]; + } + } + return null; + } + + $("#characterMetadataFileInput").on('change', function(event) { + expertViewModel.characterMetadataSuccessMessage(null); + expertViewModel.characterMetadataErrorMessage(null); + + var file = event.target.files[0]; + if(!file){ + return; + } + Papa.parse(file, { + header: true, + complete: function(results) { + var data = results.data; + + var existingCharacterDataSetTraits = expertViewModel.characterDataSetTraits(); + for(i=0;i'; - //maptitle = '
ALA reported occurences'; + //maptitle = '
ALA reported occurrences'; name = "" + node.name + ""; } else { diff --git a/grails-app/assets/javascripts/js/Records.js b/grails-app/assets/javascripts/js/Records.js index 8661ee0a..8c01423e 100644 --- a/grails-app/assets/javascripts/js/Records.js +++ b/grails-app/assets/javascripts/js/Records.js @@ -56,7 +56,7 @@ var Records = function (c) { } else { this.displayTitle = ko.observable(opt.title ||''); } - + this.canDelete = ko.observable(opt.canDelete == null ? true : opt.canDelete); }; var FormModel = function (opt) { @@ -182,6 +182,7 @@ var Records = function (c) { this.lists = ko.observableArray([]); this.selectedValue = ko.observable(); + this.edit = ko.observable(); /** * Disassociate a dataset from the system. @@ -428,11 +429,11 @@ var Records = function (c) { * called when a new data resource is selected */ this.updateMap = function(){ - if (dataresourceViewModel.selectedValue() === undefined) { + var sel = dataresourceViewModel.selectedValue(); + if (sel === undefined) { return } - var sel = dataresourceViewModel.selectedValue(); var layer = sel.layerUrl(), biocacheServiceUrl = sel.biocacheServiceUrl(); pj.clearQid(); @@ -477,19 +478,21 @@ var Records = function (c) { } this.save = function () { - if (!(config.edit && !config.firstInitialisation) ) { + if (!(dataresourceViewModel.edit() && !config.firstInitialisation) ) { return; } - var sync = {id: config.phyloId, sourceId: dataresourceViewModel.selectedValue().id}; + var sel = dataresourceViewModel.selectedValue(); + var sourceId = (sel == null ? null : sel.id); + var sync = {id: config.phyloId, sourceId: sourceId}; $.ajax({ url: config.syncUrl, data: sync, success: function (data) { - console.log('saved records in database!'); + console.log('saved records in database id=' + config.phyloId + ', sourceId=' + sourceId + ' !'); }, error: function () { - console.log('error saving records!'); + console.log('error saving records id=' + config.phyloId + ', sourceId=' + sourceId + ' !'); } }); } @@ -500,9 +503,18 @@ var Records = function (c) { records.on('sourcechanged', this.save); - if(config.edit){ - $("#csvFormRecordsUnavailable").hide(); - } else { - $("#csvFormRecords").hide(); - } + dataresourceViewModel.edit.subscribe(function(newValue) { + if(newValue){ + $("#csvFormRecordsUnavailable").hide(); + $("#csvFormRecords").show(); + } else { + $("#csvFormRecordsUnavailable").show(); + $("#csvFormRecords").hide(); + } + }); + dataresourceViewModel.edit(config.edit); + + //Expose this for use by other tabs + this.config = config; + this.dataresourceViewModel = dataresourceViewModel; } \ No newline at end of file diff --git a/grails-app/assets/javascripts/js/application.js b/grails-app/assets/javascripts/js/application.js index 02b9da06..58c53def 100644 --- a/grails-app/assets/javascripts/js/application.js +++ b/grails-app/assets/javascripts/js/application.js @@ -66,4 +66,12 @@ function addWidgetForm(){ // Provide some basic currying to the user return data ? fn( data ) : fn; }; -})(); \ No newline at end of file +})(); + +function dateToString(date) { + if (date) { + var dateObj = new Date(date); + return dateObj.toLocaleDateString() + ' ' + dateObj.toLocaleTimeString(); + } + return date; +} diff --git a/grails-app/assets/javascripts/thirdparty/papaparse.min.js b/grails-app/assets/javascripts/thirdparty/papaparse.min.js new file mode 100644 index 00000000..48c3ad92 --- /dev/null +++ b/grails-app/assets/javascripts/thirdparty/papaparse.min.js @@ -0,0 +1,7 @@ +/* @license +Papa Parse +v5.1.0 +https://github.com/mholt/PapaParse +License: MIT +*/ +!function(e,t){"function"==typeof define&&define.amd?define([],t):"object"==typeof module&&"undefined"!=typeof exports?module.exports=t():e.Papa=t()}(this,function s(){"use strict";var f="undefined"!=typeof self?self:"undefined"!=typeof window?window:void 0!==f?f:{};var n=!f.document&&!!f.postMessage,o=n&&/blob:/i.test((f.location||{}).protocol),a={},h=0,b={parse:function(e,t){var r=(t=t||{}).dynamicTyping||!1;q(r)&&(t.dynamicTypingFunction=r,r={});if(t.dynamicTyping=r,t.transform=!!q(t.transform)&&t.transform,t.worker&&b.WORKERS_SUPPORTED){var i=function(){if(!b.WORKERS_SUPPORTED)return!1;var e=(r=f.URL||f.webkitURL||null,i=s.toString(),b.BLOB_URL||(b.BLOB_URL=r.createObjectURL(new Blob(["(",i,")();"],{type:"text/javascript"})))),t=new f.Worker(e);var r,i;return t.onmessage=_,t.id=h++,a[t.id]=t}();return i.userStep=t.step,i.userChunk=t.chunk,i.userComplete=t.complete,i.userError=t.error,t.step=q(t.step),t.chunk=q(t.chunk),t.complete=q(t.complete),t.error=q(t.error),delete t.worker,void i.postMessage({input:e,config:t,workerId:i.id})}var n=null;b.NODE_STREAM_INPUT,"string"==typeof e?n=t.download?new l(t):new p(t):!0===e.readable&&q(e.read)&&q(e.on)?n=new m(t):(f.File&&e instanceof File||e instanceof Object)&&(n=new c(t));return n.stream(e)},unparse:function(e,t){var n=!1,_=!0,g=",",v="\r\n",s='"',a=s+s,r=!1,i=null;!function(){if("object"!=typeof t)return;"string"!=typeof t.delimiter||b.BAD_DELIMITERS.filter(function(e){return-1!==t.delimiter.indexOf(e)}).length||(g=t.delimiter);("boolean"==typeof t.quotes||"function"==typeof t.quotes||Array.isArray(t.quotes))&&(n=t.quotes);"boolean"!=typeof t.skipEmptyLines&&"string"!=typeof t.skipEmptyLines||(r=t.skipEmptyLines);"string"==typeof t.newline&&(v=t.newline);"string"==typeof t.quoteChar&&(s=t.quoteChar);"boolean"==typeof t.header&&(_=t.header);if(Array.isArray(t.columns)){if(0===t.columns.length)throw new Error("Option columns is empty");i=t.columns}void 0!==t.escapeChar&&(a=t.escapeChar+s)}();var o=new RegExp(U(s),"g");"string"==typeof e&&(e=JSON.parse(e));if(Array.isArray(e)){if(!e.length||Array.isArray(e[0]))return u(null,e,r);if("object"==typeof e[0])return u(i||h(e[0]),e,r)}else if("object"==typeof e)return"string"==typeof e.data&&(e.data=JSON.parse(e.data)),Array.isArray(e.data)&&(e.fields||(e.fields=e.meta&&e.meta.fields),e.fields||(e.fields=Array.isArray(e.data[0])?e.fields:h(e.data[0])),Array.isArray(e.data[0])||"object"==typeof e.data[0]||(e.data=[e.data])),u(e.fields||[],e.data||[],r);throw new Error("Unable to serialize unrecognized input");function h(e){if("object"!=typeof e)return[];var t=[];for(var r in e)t.push(r);return t}function u(e,t,r){var i="";"string"==typeof e&&(e=JSON.parse(e)),"string"==typeof t&&(t=JSON.parse(t));var n=Array.isArray(e)&&0=this._config.preview;if(o)f.postMessage({results:n,workerId:b.WORKER_ID,finished:a});else if(q(this._config.chunk)&&!t){if(this._config.chunk(n,this._handle),this._handle.paused()||this._handle.aborted())return void(this._halted=!0);n=void 0,this._completeResults=void 0}return this._config.step||this._config.chunk||(this._completeResults.data=this._completeResults.data.concat(n.data),this._completeResults.errors=this._completeResults.errors.concat(n.errors),this._completeResults.meta=n.meta),this._completed||!a||!q(this._config.complete)||n&&n.meta.aborted||(this._config.complete(this._completeResults,this._input),this._completed=!0),a||n&&n.meta.paused||this._nextChunk(),n}this._halted=!0},this._sendError=function(e){q(this._config.error)?this._config.error(e):o&&this._config.error&&f.postMessage({workerId:b.WORKER_ID,error:e,finished:!1})}}function l(e){var i;(e=e||{}).chunkSize||(e.chunkSize=b.RemoteChunkSize),u.call(this,e),this._nextChunk=n?function(){this._readChunk(),this._chunkLoaded()}:function(){this._readChunk()},this.stream=function(e){this._input=e,this._nextChunk()},this._readChunk=function(){if(this._finished)this._chunkLoaded();else{if(i=new XMLHttpRequest,this._config.withCredentials&&(i.withCredentials=this._config.withCredentials),n||(i.onload=y(this._chunkLoaded,this),i.onerror=y(this._chunkError,this)),i.open("GET",this._input,!n),this._config.downloadRequestHeaders){var e=this._config.downloadRequestHeaders;for(var t in e)i.setRequestHeader(t,e[t])}if(this._config.chunkSize){var r=this._start+this._config.chunkSize-1;i.setRequestHeader("Range","bytes="+this._start+"-"+r)}try{i.send()}catch(e){this._chunkError(e.message)}n&&0===i.status&&this._chunkError()}},this._chunkLoaded=function(){4===i.readyState&&(i.status<200||400<=i.status?this._chunkError():(this._start+=i.responseText.length,this._finished=!this._config.chunkSize||this._start>=function(e){var t=e.getResponseHeader("Content-Range");if(null===t)return-1;return parseInt(t.substr(t.lastIndexOf("/")+1))}(i),this.parseChunk(i.responseText)))},this._chunkError=function(e){var t=i.statusText||e;this._sendError(new Error(t))}}function c(e){var i,n;(e=e||{}).chunkSize||(e.chunkSize=b.LocalChunkSize),u.call(this,e);var s="undefined"!=typeof FileReader;this.stream=function(e){this._input=e,n=e.slice||e.webkitSlice||e.mozSlice,s?((i=new FileReader).onload=y(this._chunkLoaded,this),i.onerror=y(this._chunkError,this)):i=new FileReaderSync,this._nextChunk()},this._nextChunk=function(){this._finished||this._config.preview&&!(this._rowCount=this._input.size,this.parseChunk(e.target.result)},this._chunkError=function(){this._sendError(i.error)}}function p(e){var r;u.call(this,e=e||{}),this.stream=function(e){return r=e,this._nextChunk()},this._nextChunk=function(){if(!this._finished){var e=this._config.chunkSize,t=e?r.substr(0,e):r;return r=e?r.substr(e):"",this._finished=!r,this.parseChunk(t)}}}function m(e){u.call(this,e=e||{});var t=[],r=!0,i=!1;this.pause=function(){u.prototype.pause.apply(this,arguments),this._input.pause()},this.resume=function(){u.prototype.resume.apply(this,arguments),this._input.resume()},this.stream=function(e){this._input=e,this._input.on("data",this._streamData),this._input.on("end",this._streamEnd),this._input.on("error",this._streamError)},this._checkIsFinished=function(){i&&1===t.length&&(this._finished=!0)},this._nextChunk=function(){this._checkIsFinished(),t.length?this.parseChunk(t.shift()):r=!0},this._streamData=y(function(e){try{t.push("string"==typeof e?e:e.toString(this._config.encoding)),r&&(r=!1,this._checkIsFinished(),this.parseChunk(t.shift()))}catch(e){this._streamError(e)}},this),this._streamError=y(function(e){this._streamCleanUp(),this._sendError(e)},this),this._streamEnd=y(function(){this._streamCleanUp(),i=!0,this._streamData("")},this),this._streamCleanUp=y(function(){this._input.removeListener("data",this._streamData),this._input.removeListener("end",this._streamEnd),this._input.removeListener("error",this._streamError)},this)}function r(g){var a,o,h,i=Math.pow(2,53),n=-i,s=/^\s*-?(\d*\.?\d+|\d+\.?\d*)(e[-+]?\d+)?\s*$/i,u=/(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))/,t=this,r=0,f=0,d=!1,e=!1,l=[],c={data:[],errors:[],meta:{}};if(q(g.step)){var p=g.step;g.step=function(e){if(c=e,_())m();else{if(m(),0===c.data.length)return;r+=e.data.length,g.preview&&r>g.preview?o.abort():p(c,t)}}}function v(e){return"greedy"===g.skipEmptyLines?""===e.join("").trim():1===e.length&&0===e[0].length}function m(){if(c&&h&&(k("Delimiter","UndetectableDelimiter","Unable to auto-detect delimiting character; defaulted to '"+b.DefaultDelimiter+"'"),h=!1),g.skipEmptyLines)for(var e=0;e=l.length?"__parsed_extra":l[r]),g.transform&&(s=g.transform(s,n)),s=y(n,s),"__parsed_extra"===n?(i[n]=i[n]||[],i[n].push(s)):i[n]=s}return g.header&&(r>l.length?k("FieldMismatch","TooManyFields","Too many fields: expected "+l.length+" fields but parsed "+r,f+t):r=i.length/2?"\r\n":"\r"}(e,i)),h=!1,g.delimiter)q(g.delimiter)&&(g.delimiter=g.delimiter(e),c.meta.delimiter=g.delimiter);else{var n=function(e,t,r,i,n){var s,a,o,h;n=n||[",","\t","|",";",b.RECORD_SEP,b.UNIT_SEP];for(var u=0;u=L)return R(!0)}else for(g=z,z++;;){if(-1===(g=a.indexOf(O,g+1)))return t||u.push({type:"Quotes",code:"MissingQuotes",message:"Quoted field unterminated",row:h.length,index:z}),w();if(g===i-1)return w(a.substring(z,g).replace(_,O));if(O!==M||a[g+1]!==M){if(O===M||0===g||a[g-1]!==M){var y=E(-1===m?p:Math.min(p,m));if(a[g+1+y]===D){f.push(a.substring(z,g).replace(_,O)),a[z=g+1+y+e]!==O&&(g=a.indexOf(O,z)),p=a.indexOf(D,z),m=a.indexOf(I,z);break}var k=E(m);if(a.substr(g+1+k,n)===I){if(f.push(a.substring(z,g).replace(_,O)),C(g+1+k+n),p=a.indexOf(D,z),g=a.indexOf(O,z),o&&(S(),j))return R();if(L&&h.length>=L)return R(!0);break}u.push({type:"Quotes",code:"InvalidQuotes",message:"Trailing quote on quoted field is malformed",row:h.length,index:z}),g++}}else g++}return w();function b(e){h.push(e),d=z}function E(e){var t=0;if(-1!==e){var r=a.substring(g+1,e);r&&""===r.trim()&&(t=r.length)}return t}function w(e){return t||(void 0===e&&(e=a.substr(z)),f.push(e),z=i,b(f),o&&S()),R()}function C(e){z=e,b(f),f=[],m=a.indexOf(I,z)}function R(e,t){return{data:t||!1?h[0]:h,errors:u,meta:{delimiter:D,linebreak:I,aborted:j,truncated:!!e,cursor:d+(r||0)}}}function S(){A(R(void 0,!0)),h=[],u=[]}function x(e,t,r){var i={nextDelim:void 0,quoteSearch:void 0},n=a.indexOf(O,t+1);if(t - sendError(SC_UNAUTHORIZED, message ?: "You do not have permission to perform the requested action.") + sendError(SC_UNAUTHORIZED, message ?: "You do not have permission to perform the requested action.") + } + + def renderJSONNotAuthorised = {String message = null -> + renderJSONErrorMsg(SC_UNAUTHORIZED, message ?: "You do not have permission to perform the requested action.") } protected void notFound(String message = null) { sendError(SC_NOT_FOUND, message ?: "No matching record was found.") } + protected void error(String message = null) { + sendError(SC_INTERNAL_SERVER_ERROR, message ?: "An unexpected error has occurred.") + } + + protected void renderJSONError(String message = null) { + renderJSONErrorMsg(SC_INTERNAL_SERVER_ERROR, message ?: "An unexpected error has occurred.") + } + def success = { resp -> - response.status = SC_OK - response.setContentType(CONTEXT_TYPE_JSON) - render resp as JSON + renderJSONResponse(SC_OK, resp) } + protected void renderJSONResponse(int status, Object resp) { + response.status = status + response.setContentType(CONTEXT_TYPE_JSON) + render resp as JSON + } + + private renderJSONErrorMsg(int status, String msg) { + Map resp = [ + error: msg + ] + renderJSONResponse(status, resp) + } + def saveFailed = { sendError(SC_INTERNAL_SERVER_ERROR) } def sendError = {int status, String msg = null -> +log.error("About to sendError", new RuntimeException("About to sendError")) response.status = status response.sendError(status, msg) } diff --git a/grails-app/controllers/au/org/ala/phyloviz/CharactersController.groovy b/grails-app/controllers/au/org/ala/phyloviz/CharactersController.groovy index 01c69647..f82c8ee4 100644 --- a/grails-app/controllers/au/org/ala/phyloviz/CharactersController.groovy +++ b/grails-app/controllers/au/org/ala/phyloviz/CharactersController.groovy @@ -1,6 +1,7 @@ package au.org.ala.phyloviz import grails.converters.JSON +import org.springframework.web.multipart.MultipartHttpServletRequest class CharactersController { @@ -15,7 +16,7 @@ class CharactersController { */ def list() { - List characterLists = charactersService.getCharacterListsByOwner() + List characterLists = charactersService.getCharacterListsByOwner(params.initCharacterResourceId) List result = treeService.filterCharacterListsByTree(Integer.parseInt(params.treeId), characterLists) if (params.callback) { @@ -25,12 +26,35 @@ class CharactersController { } } + def isResourceCompatibleWithTree() { + def characters = Characters.get(params.id) + List characterLists = new ArrayList(1) + characterLists.add(characters) + List list = treeService.filterCharacterListsByTree(Integer.parseInt(params.treeId), charactersService.getCharUrl(characterLists)) + + def result = [ + isCompatibleWithTree: (list.size() > 0) + ] + + if (params.callback) { + render(contentType: 'text/javascript', text: "${params.callback}(${result as JSON})") + } else { + render(contentType: 'application/json', text: result as JSON) + } + } + def deleteResource(){ def alaId = authService.getUserId() def characters = Characters.get(params.id) - if (characters && characters.owner.getUserId().toString() == alaId){ - characters.delete(flush:true) + log.debug("In deleteResource id=${params.id}, characters=${characters}") + + if (characters && characters.owner.getUserId().toString() == alaId && ! Boolean.FALSE.equals(characters.canDelete)){ + log.debug("In deleteResource about to delete") + Object eUresult = Phylo.executeUpdate("update Phylo p set p.characterSource = null where p.characterSource=:characters", + [characters: characters]) + log.debug("In deleteResource executeUpdate returned=${eUresult}") + characters.delete(flush:true) def result = [success:true] render( text: result as JSON, contentType: 'application/json') } else { @@ -80,4 +104,36 @@ class CharactersController { render(contentType: 'application/json', text: result as JSON) } } + + /** + * save uploaded images + */ + def saveImages(){ + + def id + def imageFiles = isMultipartRequest() ? request.getFile('imagesFiles[]') : null; + def result = new ArrayList(); + for (Object file : imageFiles) { + def imageResult = [ : ] + imageResult['fileName'] = file.originalFilename + imageResult['id'] = charactersService.saveImage(file.originalFilename, file.bytes) + imageResult['url'] = createLink() // TODO Jasen + result.add(imageResult) + } + +//TODO Jasen - ran out of time to finish this +// result['error'] = 'error executing function'; +// response.sendError(400) +// } + + if(params.callback){ + render(contentType: 'text/javascript', text: "${params.callback}(${result as JSON})") + } else { + render(contentType: 'application/json', text: result as JSON) + } + } + + private boolean isMultipartRequest() { + request instanceof MultipartHttpServletRequest + } } diff --git a/grails-app/controllers/au/org/ala/phyloviz/PhyloController.groovy b/grails-app/controllers/au/org/ala/phyloviz/PhyloController.groovy index db2da238..8c6a2d11 100644 --- a/grails-app/controllers/au/org/ala/phyloviz/PhyloController.groovy +++ b/grails-app/controllers/au/org/ala/phyloviz/PhyloController.groovy @@ -2,6 +2,7 @@ package au.org.ala.phyloviz import grails.converters.JSON import groovy.json.JsonBuilder +import org.apache.commons.lang3.StringUtils import static org.springframework.http.HttpStatus.* /** @@ -13,7 +14,9 @@ class PhyloController extends BaseController { def treeService def phyloService def authService - + def userService + def doiService + static allowedMethods = [save: "POST", update: "PUT", delete: "DELETE"] def list(Integer max) { @@ -22,21 +25,42 @@ class PhyloController extends BaseController { } def show(Phylo phyloInstance) { - def tree = Tree.findById(phyloInstance.getStudyid()); - def userId = authService.getUserId(); - if(userId != ""){ - userId = userId instanceof String?Long.parseLong(userId):userId; - } - - Boolean edit = false - log.debug("user id : ${userId instanceof String}") - log.debug("owner id: ${phyloInstance.getOwner()?.userId}"); - if( phyloInstance.getOwner()?.userId == userId && userId != null){ - edit = true - log.debug('editable'); - } - - respond phyloInstance, model: [ tree: tree, userId: userId, edit: edit, studyId: phyloInstance.getStudyid(), phyloInstance: phyloInstance, isDemonstration: !edit] + if (! phyloService.isAuthorisedToView(phyloInstance)) { +// notAuthorised "Only the visualisation owner or an administrator can view an unapproved visualisation" + if (phyloService.isLoggedIn() || !phyloInstance) { + notAuthorised "Only a logged on user can view a visualisation that is not a demonstration" + } else { + respond phyloInstance, model: [ showLoginToViewMsg: true ] + } + } else { + def tree = Tree.findById(phyloInstance.getStudyid()); + def userId = authService.getUserId(); + + log.debug("In show, user id : ${userId}") + log.debug("In show, owner id: ${phyloInstance.getOwner()?.userId}"); + log.debug("In show, source: ${phyloInstance.getSource()}"); + + Boolean isDemonstration = phyloService.isDemonstrationViz(phyloInstance) + Boolean isOwner = phyloService.isOwner(phyloInstance) + Boolean edit = phyloService.isAuthorised(phyloInstance) + Boolean userCanApprove = phyloService.isAuthorisedToApprove(phyloInstance) + Boolean userCanReject = phyloService.isAuthorisedToReject(phyloInstance) + Boolean userCanReinstate = phyloService.isAuthorisedToReinstate(phyloInstance) + Boolean userIsWorkflowAdmin = phyloService.isAuthorisedWorkflowAdmin() + Boolean showWorkflowStatusEntries = isOwner || userIsWorkflowAdmin + Boolean isPublished = phyloService.isPublished(phyloInstance) + + log.debug("In show, edit: ${edit}, userCanApprove: ${userCanApprove}, userCanReject: ${userCanReject}, userCanReinstate: ${userCanReinstate}"); + + phyloService.prepopulateEmptyFields(phyloInstance) + + respond phyloInstance, model: [ tree: tree, userId: userId, edit: edit, studyId: phyloInstance.getStudyid(), + phyloInstance: phyloInstance, isOwner: isOwner, isDemonstration: isDemonstration, userCanApprove: userCanApprove, + userCanReject: userCanReject, userCanReinstate: userCanReinstate, showWorkflowStatusEntries: showWorkflowStatusEntries, + userName: userService.getCurrentUserDisplayName(), licences: doiService.getLicences(), userIsWorkflowAdmin: userIsWorkflowAdmin, + isPublished: isPublished, showLoginToViewMsg: false + ] + } } def deleteViz() { @@ -275,51 +299,120 @@ class PhyloController extends BaseController { * @return */ def saveHabitat(Phylo phyloInstance){ - if(!phyloService.isAuthorised(phyloInstance.getOwner())){ + if(!phyloService.isAuthorised(phyloInstance)){ notAuthorised "Only owner can edit this visualisation"; } String habInit = params.json - log.debug(habInit); + log.debug("In saveHabitat about to setHabitat to ${habInit}"); phyloInstance.setHabitat(JSON.parse(habInit).toString()); phyloInstance.save(flush: true); render(contentType: 'application/json',text:"{\"message\":\"success\"}"); } /** - * save the habitats json string into database + * save the characters/traits json string into database * @param phyloInstance * @return */ def saveCharacters(Phylo phyloInstance){ - if(!phyloService.isAuthorised(phyloInstance.getOwner())){ + if(!phyloService.isAuthorised(phyloInstance)){ notAuthorised "Only owner can edit this visualisation"; } String charInit = params.json - log.debug(charInit); + log.debug("In saveCharacters about to setCharacters to ${charInit}"); phyloInstance.setCharacters(JSON.parse(charInit).toString()); + phyloInstance.setCharacterSource(null); phyloInstance.save(flush: true); render(contentType: 'application/json',text:"{\"message\":\"success\"}"); } /** - * save the habitats json string into database + * save the reference to the character traits data set into the database + * @param phyloInstance + * @return + */ + def saveCharacterSource(Phylo phyloInstance){ + if(!phyloService.isAuthorised(phyloInstance)){ + notAuthorised "Only owner can edit this visualisation"; + } + + String id = params.characterSourceId + log.debug("In saveCharacterSource about to setSource to '${id}'"); + + Characters characters = (StringUtils.isNotBlank(id) ? Characters.findById(id) : null); + if (StringUtils.isNotBlank(id) && characters == null) { + throw new IllegalArgumentException("In saveCharacterSource unknown characterSourceId '" + id + "'"); + } + if (characters != null) { + phyloInstance.setCharacters(null); + } + phyloInstance.setCharacterSource(characters); + phyloInstance.save(flush: true); + render(contentType: 'application/json',text:"{\"message\":\"success\"}"); + } + + /** + * save the reference to the occurrence distribution into the database * @param phyloInstance * @return */ def saveSource(Phylo phyloInstance){ - if(!phyloService.isAuthorised(phyloInstance.getOwner())){ + if(!phyloService.isAuthorised(phyloInstance)){ notAuthorised "Only owner can edit this visualisation"; } String id = params.sourceId - log.debug(id); + log.debug("In saveSource about to setSource to '${id}'"); phyloInstance.setSource(id); phyloInstance.save(flush: true); render(contentType: 'application/json',text:"{\"message\":\"success\"}"); } + /** + * save the visualisation metadata to the database + * @param phyloInstance + * @return + */ + def saveExpertVisualisation(Phylo phyloInstance){ + String responseMessage = ""; + String processDescription = "save metadata"; + try { + phyloService.saveMetadata(phyloInstance, params) + + responseMessage = "Successfully saved metadata." + + } catch (Exception e) { + log.error("Exception while trying to " + processDescription + " for Phylo with Id=" + phyloInstance?.getId(), e); + return renderJSONError(responseMessage + " Failed to " + processDescription + "."); + } + + Map response = [ + message: responseMessage + ] + + success(response); + } + + /** + * change the workflow status of the visualisation + * @param phyloInstance + * @return + */ + def changeWorkflowStatus(Phylo phyloInstance){ + String visualisationURL = grailsApplication.config.serverURL + + createLink(controller: 'phylo', action: 'show', params: [id: phyloInstance.id]) + + Map response = phyloService.changeWorkflowStatus(phyloInstance, params, visualisationURL) + + if (response.containsKey('userCanApprove')) { + success(response); + } else { + renderJSONError(response.get('responseMessage')) + } + } + /** * start page for phylolink */ @@ -342,7 +435,7 @@ class PhyloController extends BaseController { * save pj settings to the database. currently only saving clicked node id. */ def savePjSettings(Phylo phyloInstance){ - if(!phyloService.isAuthorised(phyloInstance.getOwner())){ + if(!phyloService.isAuthorised(phyloInstance)){ notAuthorised "Only owner can edit this visualisation"; } @@ -353,7 +446,7 @@ class PhyloController extends BaseController { if(!phyloInstance.hasErrors()){ render(contentType: 'application/json',text: ["message":"success"] as JSON); } else { - render(contentType: 'application/json',text: ["message":"failed"] as JSON, status: 500); + render(contentType: 'application/json',text: ["message":"failed"] as JSON, status: INTERNAL_SERVER_ERROR.value()); } } } \ No newline at end of file diff --git a/grails-app/controllers/au/org/ala/phyloviz/SandboxController.groovy b/grails-app/controllers/au/org/ala/phyloviz/SandboxController.groovy index 4688fddb..1c388d83 100644 --- a/grails-app/controllers/au/org/ala/phyloviz/SandboxController.groovy +++ b/grails-app/controllers/au/org/ala/phyloviz/SandboxController.groovy @@ -21,7 +21,14 @@ class SandboxController { def deleteResource(){ def alaId = authService.getUserId() def sandbox = Sandbox.get(params.id) - if(sandbox && sandbox.owner.getUserId().toString() == alaId){ + + log.debug("In deleteResource id=${params.id}, sandbox=${sandbox}") + + if(sandbox && sandbox.owner.getUserId().toString() == alaId && ! Boolean.FALSE.equals(sandbox.canDelete)){ + log.debug("In deleteResource about to delete") + Object eUresult = Phylo.executeUpdate("update Phylo p set p.source = null where p.source=:source", + [source: String.valueOf(params.id)]) + log.debug("In deleteResource executeUpdate returned=${eUresult}") sandbox.delete(flush:true) def result = [success:true] render( text: result as JSON, contentType: 'application/json') diff --git a/grails-app/controllers/au/org/ala/phyloviz/TreeController.groovy b/grails-app/controllers/au/org/ala/phyloviz/TreeController.groovy index 5cb9c6e7..2d040e56 100644 --- a/grails-app/controllers/au/org/ala/phyloviz/TreeController.groovy +++ b/grails-app/controllers/au/org/ala/phyloviz/TreeController.groovy @@ -41,8 +41,22 @@ class TreeController extends BaseController { def userId = authService.getUserId() def user = userId != null ? Owner.findByUserId(userId) : Owner.findByDisplayName('Guest') if( user ){ - params.user = user - render( view: 'create', model: [ tree: Tree.findById(params.id ? params.id : params.studyId ), isAdmin:userService.userIsSiteAdmin() ]) + def treeId = (params.id ? params.id : params.studyId) + String hql = "select count(p) " + + "from Phylo as p " + + "where p.status = '" + Phylo.WorkflowStatus.Approved.getKey() + "' " + + "and p.studyid = :treeId" + Map hqlParams = [ treeId: treeId ] + def rowCount = Phylo.executeQuery(hql, hqlParams).get(0) + log.debug("In edit hql='${hql}', treeId=${treeId}, rowCount=${rowCount}") + if (rowCount == 0) { + params.user = user + render( view: 'create', model: [ tree: Tree.findById(treeId), isAdmin:userService.userIsSiteAdmin() ]) + } else { + def msg = "You can't edit this tree as it is associated with a Published visualisation." + flash.message = msg + treeAdmin(); + } } else { def msg = "Failed to detect current user details. Are you logged in?" flash.message = msg diff --git a/grails-app/controllers/au/org/ala/phyloviz/WizardController.groovy b/grails-app/controllers/au/org/ala/phyloviz/WizardController.groovy index 6158026d..5d61a2b1 100644 --- a/grails-app/controllers/au/org/ala/phyloviz/WizardController.groovy +++ b/grails-app/controllers/au/org/ala/phyloviz/WizardController.groovy @@ -1,11 +1,12 @@ package au.org.ala.phyloviz class WizardController { + static final int DEFAULT_PAGE_SIZE = 10 + def treeService def phyloService def userService def authService - def utilsService static allowedMethods = [pickMethod: 'POST', save: 'POST'] @@ -40,6 +41,9 @@ class WizardController { case 'demo': redirect(action: 'demo') break; + case 'published': + redirect(action: 'published') + break; case 'treeAdmin': redirect(controller: "tree", action: 'treeAdmin') break; @@ -58,12 +62,15 @@ class WizardController { //number of visualisations for this user def numberOfVisualisations = userId != null ? myViz().viz.size() : 0 + def noOfVizThatRequireMyAttention = userId != null ? phyloService.listVizThatRequireMyAttention().size() : 0 + render(view: '/wizard/pick', contentType: 'text/html', model: [ numberOfTrees: numberOfTrees, numberOfVisualisations: numberOfVisualisations, loggedIn: userId != null, - isAdmin: userService.userIsSiteAdmin() + isAdmin: userService.userIsSiteAdmin(), + noOfVizThatRequireMyAttention: noOfVizThatRequireMyAttention ] ) } @@ -196,6 +203,22 @@ class WizardController { [viz: myViz, name: name, isDemonstration: false] } + def vizThatNeedMyAttention() { + def vizThatRequireMyAttention = phyloService.listVizThatRequireMyAttention() + [viz: vizThatRequireMyAttention] + } + + def published() { + int pageSize = params.getInt("pageSize", DEFAULT_PAGE_SIZE) + int offset = params.getInt("offset", 0) + String filter = params.filter?:"" + + render view: "published", + model: [viz : phyloService.listPublishedViz(filter, pageSize, offset, "doiCreationDate", "desc"), + offset : offset, + pageSize: pageSize] + } + /** * displays a ui to search TreeBASE */ @@ -219,7 +242,7 @@ class WizardController { * demo */ def demo() { - def owner = utilsService.guestAccount(); + def owner = phyloService.getOwnerOfDemoViz(); def name; if (owner == null) { flash.message = 'No demonstration visualisations found.' diff --git a/grails-app/domain/au/org/ala/phyloviz/CharacterTrait.groovy b/grails-app/domain/au/org/ala/phyloviz/CharacterTrait.groovy new file mode 100644 index 00000000..14e541b4 --- /dev/null +++ b/grails-app/domain/au/org/ala/phyloviz/CharacterTrait.groovy @@ -0,0 +1,71 @@ +package au.org.ala.phyloviz + +import org.apache.commons.lang3.StringUtils + +class CharacterTrait { + + public enum TraitType { + Continuous( 'Continuous' ), + Integer( 'Integer' ), + Discrete( 'Discrete' ), + Categorical( 'Categorical' ) + final String value; + TraitType(String value){ + this.value = value + } + String toString(){ + value; + } + String getKey(){ + name() + } + public static TraitType forValue(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + for (TraitType traitType : values()) { + if (traitType.toString().equalsIgnoreCase(value)) { + return traitType; + } + } + throw new RuntimeException("Invalid TraitType value=" + value); + } + public static TraitType forKey(String key) { + if (StringUtils.isNotBlank(key)) { + for (TraitType traitType : values()) { + if (traitType.getKey().equalsIgnoreCase(key)) { + return traitType; + } + } + } + throw new RuntimeException("Invalid TraitType key=" + key); + } + } + + Characters characters + static belongsTo = [Characters] + String title + String description + String type + CharacterTraitImage image + + static mapping = { + description type:'text' + } + static constraints = { + characters(nullable: false) + title(nullable: false) + description(nullable: true) + type(nullable: true) + image(nullable: true) + } + + TraitType getTraitType() { + type ? TraitType.forKey(type) : null + } + void setTraitType(TraitType traitType) { + type = traitType ? traitType.getKey() : null + } + + static transients = ['traitType'] +} \ No newline at end of file diff --git a/grails-app/domain/au/org/ala/phyloviz/CharacterTraitImage.groovy b/grails-app/domain/au/org/ala/phyloviz/CharacterTraitImage.groovy new file mode 100644 index 00000000..7b95124c --- /dev/null +++ b/grails-app/domain/au/org/ala/phyloviz/CharacterTraitImage.groovy @@ -0,0 +1,21 @@ +package au.org.ala.phyloviz + +class CharacterTraitImage { + + Owner owner + String name + byte[] data + static belongsTo = [Owner] + + static mapping = { + description type:'text' + columns { + data type:'blob' + } + } + static constraints = { + owner(nullable: false) + name(nullable: false) + data(nullable: false, maxSize: 1024 * 1024 * 2) // Limit upload file size to 2MB + } +} \ No newline at end of file diff --git a/grails-app/domain/au/org/ala/phyloviz/Characters.groovy b/grails-app/domain/au/org/ala/phyloviz/Characters.groovy index 9e2af7f9..c22d34a2 100644 --- a/grails-app/domain/au/org/ala/phyloviz/Characters.groovy +++ b/grails-app/domain/au/org/ala/phyloviz/Characters.groovy @@ -5,9 +5,19 @@ class Characters { static belongsTo = [Owner] String title String drid + List characterTraits = new ArrayList() + Boolean canDelete + static hasMany = [ + characterTraits: CharacterTrait + ] + static mapping = { + characterTrait cascade:"all" + canDelete defaultValue: true + } static constraints = { owner(nullable: false) title(nullable: false) drid(nullable: false) + canDelete(nullable: true) } } \ No newline at end of file diff --git a/grails-app/domain/au/org/ala/phyloviz/Phylo.groovy b/grails-app/domain/au/org/ala/phyloviz/Phylo.groovy index b87bdbf8..adfe15a9 100644 --- a/grails-app/domain/au/org/ala/phyloviz/Phylo.groovy +++ b/grails-app/domain/au/org/ala/phyloviz/Phylo.groovy @@ -1,10 +1,53 @@ package au.org.ala.phyloviz +import java.util.Date +import java.util.List +import org.apache.commons.lang3.StringUtils + /** * Created by Temi Varghese on 17/06/2014. */ class Phylo { - Owner owner + + public enum WorkflowStatus{ + Draft( 'Draft' ), + Requested_Approval( 'Requested Approval' ), + Approved( 'Published' ), + Revision_Required( 'Revision Required' ), + No_Longer_Required( 'No Longer Required' ) + final String value; + WorkflowStatus(String value){ + this.value = value + } + String toString(){ + value; + } + String getKey(){ + name() + } + public static WorkflowStatus forValue(String value) { + if (StringUtils.isNotBlank(value)) { + for (WorkflowStatus workflowStatus : values()) { + if (workflowStatus.toString().equalsIgnoreCase(value)) { + return workflowStatus; + } + } + } + throw new RuntimeException("Invalid WorkflowStatus value=" + value); + } + public static WorkflowStatus forKey(String key) { + if (StringUtils.isNotBlank(key)) { + for (WorkflowStatus workflowStatus : values()) { + if (workflowStatus.getKey().equalsIgnoreCase(key)) { + return workflowStatus; + } + } + } + throw new RuntimeException("Invalid WorkflowStatus key=" + key); + } + } + + Owner owner static belongsTo = [Owner] Integer id String title = "unnamed" @@ -20,15 +63,49 @@ class Phylo { String characters String pjSettings String source + Characters characterSource + String genus + String species + String authors + String publishedPapers + Boolean unpublishedData + String doi + String doiURL + String doiUuid + String notes + String status = WorkflowStatus.Draft.getKey() + + WorkflowStatus getWorkflowStatus() { + status ? WorkflowStatus.forKey(status) : null + } + void setWorkflowStatus(WorkflowStatus workflowStatus) { + status = workflowStatus.getKey() + } + + static transients = ['workflowStatus'] + + Date doiCreationDate + List widgets = new ArrayList() - static hasMany = [ widgets: Widget ] + List workflowStatusEntries = new ArrayList() + static hasMany = [ + widgets: Widget, + workflowStatusEntries: WorkflowStatusEntry + ] static mapping = { widgets cascade:"all-delete-orphan" + workflowStatusEntries cascade:"all" habitat type:'text' characters type:'text' source type: 'text' pjSettings type: 'text' title defaultValue:"'Unnamed'" + genus type: 'text' + species type: 'text' + authors type: 'text' + publishedPapers type: 'text' + unpublishedData defaultValue: false + notes type: 'text' } static constraints = { treeid (nullable: false) @@ -45,6 +122,46 @@ class Phylo { pjSettings(nullable: true, blank: true) characters(nullable: true, blank: true) source(nullable: true, blank: true) + characterSource(nullable: true, blank: true) title(nullable: true) + genus(nullable: true, widget: 'textarea') // ideally nullable is false, but temporarily set to true to be compatible with existing data + species(nullable: true, widget: 'textarea') + authors(nullable: true, widget: 'textarea') // ideally nullable is false, but temporarily set to true to be compatible with existing data + publishedPapers(nullable: true, widget: 'textarea') // ideally nullable is false, but temporarily set to true to be compatible with existing data + unpublishedData(nullable: true) // ideally nullable is false, but temporarily set to true to be compatible with existing data + doi(nullable: true) + doiURL(nullable: true) + doiUuid(nullable: true) + notes(nullable: true, widget: 'textarea') + status(nullable: true, inList: WorkflowStatus.values()*.getKey()) // ideally nullable is false, but temporarily set to true to be compatible with existing data + doiCreationDate(nullable: true) // ideally nullable is false, but temporarily set to true to be compatible with existing data + workflowStatusEntries (nullable:true) } + + static String parseGenusFromSpecies(String species) { + int pos = getIndexOfOrReturnTheLengthOfTheString(species, " "); + int underscorePos = getIndexOfOrReturnTheLengthOfTheString(species, "_"); + + if (underscorePos < pos) { + pos = underscorePos; + } + + return species.substring(0, pos); + } + + static int getIndexOfOrReturnTheLengthOfTheString(String string, String character) { + int result = string.indexOf(character); + if (result < 0) { + return string.length(); + } + return result; + } + + static String listToStringWithNewLineDelimiter(List list) { + String.join("\r\n", list) + } + + static String[] getArrayForNewLineAndSemiColonDelimitedValue(String value) { + (value?:"").replaceAll("\\r\\n|\\r|\\n", ";").split(";") + } } \ No newline at end of file diff --git a/grails-app/domain/au/org/ala/phyloviz/Sandbox.groovy b/grails-app/domain/au/org/ala/phyloviz/Sandbox.groovy index 5422b83f..6f98e6f8 100644 --- a/grails-app/domain/au/org/ala/phyloviz/Sandbox.groovy +++ b/grails-app/domain/au/org/ala/phyloviz/Sandbox.groovy @@ -11,10 +11,13 @@ class Sandbox { Owner owner Boolean status Date dateCreated + Boolean canDelete static constraints = { phyloId nullable: true + canDelete nullable: true } static mapping = { status defaultValue: false + canDelete defaultValue: true } } diff --git a/grails-app/domain/au/org/ala/phyloviz/Visualization.groovy b/grails-app/domain/au/org/ala/phyloviz/Visualization.groovy index 023a855a..f5c060aa 100644 --- a/grails-app/domain/au/org/ala/phyloviz/Visualization.groovy +++ b/grails-app/domain/au/org/ala/phyloviz/Visualization.groovy @@ -1,5 +1,7 @@ package au.org.ala.phyloviz +import java.util.Date + /** * Created by Temi Varghese on 17/06/2014. */ diff --git a/grails-app/domain/au/org/ala/phyloviz/WorkflowStatusEntry.groovy b/grails-app/domain/au/org/ala/phyloviz/WorkflowStatusEntry.groovy new file mode 100644 index 00000000..e9026867 --- /dev/null +++ b/grails-app/domain/au/org/ala/phyloviz/WorkflowStatusEntry.groovy @@ -0,0 +1,39 @@ +package au.org.ala.phyloviz + +import java.util.Date +import java.util.List +import org.apache.commons.lang3.StringUtils + +/** + * Created by Jasen Schremmer on 25/09/2019. + */ +class WorkflowStatusEntry { + + Integer id + Phylo phylo + static belongsTo = [Phylo] + Owner owner + Date date + String status + String comment + + Phylo.WorkflowStatus getWorkflowStatus() { + status ? Phylo.WorkflowStatus.forKey(status) : null + } + void setWorkflowStatus(Phylo.WorkflowStatus workflowStatus) { + status = workflowStatus.getKey() + } + + static transients = ['workflowStatus'] + + static mapping = { + comment type: 'text' + } + static constraints = { + phylo(nullable: false) + owner(nullable: false) + date(nullable: false) + status(nullable: false, inList: Phylo.WorkflowStatus.values()*.getKey()) + comment(nullable: true, blank: true, widget: 'textarea') + } +} \ No newline at end of file diff --git a/grails-app/services/au/org/ala/phyloviz/CharactersService.groovy b/grails-app/services/au/org/ala/phyloviz/CharactersService.groovy index 77ab9301..04bf7f8a 100644 --- a/grails-app/services/au/org/ala/phyloviz/CharactersService.groovy +++ b/grails-app/services/au/org/ala/phyloviz/CharactersService.groovy @@ -4,6 +4,7 @@ import au.com.bytecode.opencsv.CSVReader import au.org.ala.web.AuthService import grails.converters.JSON import grails.transaction.Transactional +import org.apache.commons.lang3.StringUtils import org.grails.web.json.JSONObject import java.util.regex.Pattern @@ -16,13 +17,24 @@ class CharactersService { def grailsApplication AuthService authService - def getCharacterListsByOwner() { + def getCharacterListsByOwner(initCharacterResourceId = null) { def id = authService.getUserId() log.debug(id) def owner = Owner.findByUserId(id) def lists = Characters.findAllByOwner(owner); def slist = Characters.findAllByOwner(Owner.findByUserId(1)); lists.addAll(slist); + if (initCharacterResourceId != null) { + def found = false; + for (Characters characters : lists) { + if (String.valueOf(characters.id).equals(String.valueOf(initCharacterResourceId))) { + found = true + } + } + if (!found) { + lists.add(Characters.findById(initCharacterResourceId)) + } + } getCharUrl(lists); } @@ -37,7 +49,8 @@ class CharactersService { 'url': getUrl(list.drid), 'title':list.title, 'id': list.id, - 'listurl': listsUrl.replace('DRID', list.drid) + 'listurl': listsUrl.replace('DRID', list.drid), + 'canDelete': ! Boolean.FALSE.equals(list.canDelete) ]); } return result @@ -166,6 +179,7 @@ class CharactersService { result['url'] = url result['title'] = title result['id'] = id; + result['canDelete'] = true; } else { result['error'] = 'error executing function'; } diff --git a/grails-app/services/au/org/ala/phyloviz/DoiService.groovy b/grails-app/services/au/org/ala/phyloviz/DoiService.groovy new file mode 100644 index 00000000..878d3ea5 --- /dev/null +++ b/grails-app/services/au/org/ala/phyloviz/DoiService.groovy @@ -0,0 +1,122 @@ +package au.org.ala.phyloviz + +import grails.converters.JSON +import org.apache.commons.lang3.StringUtils +import org.apache.http.entity.ContentType + +class DoiService { + + def webServiceService + def authService + def treeService + def grailsApplication + + static final String DEFAULT_DOI_AUTHOR = "Atlas Of Living Australia" + static final String DEFAULT_DOI_PROVIDER = "ANDS" + static final String DEFAULT_DOI_DESCRIPTION = "ALA phylolink visualisation" + static final String DEFAULT_DOI_CITATIONURL_PREFIX = "https://doi.org/" + static final String DEFAULT_DOI_DISPLAY_TEMPLATE = "phylolink" + + /** + * Mint / Create a DOI for the visualisation. + * + * @param phyloInstance + * @param visualisationURL the URL to the phylolink visualisation + * @return + */ + def mintDoi(Phylo phyloInstance, String visualisationURL) throws Exception { + + String url = "${grailsApplication.config.alaDoiUrl}/api/doi" + + String appNameAuthor = grailsApplication.config.app.author?:DEFAULT_DOI_AUTHOR + String title = phyloInstance.title + if (grailsApplication.config.doi?.titlePrefix) { + title = grailsApplication.config.doi?.titlePrefix + " " + title + } + String description = grailsApplication.config.doi?.description?:DEFAULT_DOI_DESCRIPTION; + String ownerDisplayName = phyloInstance.getOwner().getDisplayName(); + + // Provider metadata based on https://documentation.ands.org.au/display/DOC/Minting+DOIs, + // https://schema.datacite.org/meta/kernel-4.1/doc/DataCite-MetadataKernel_v4.1.pdf and + // https://github.com/AtlasOfLivingAustralia/biocache-service/blob/master/src/main/java/au/org/ala/biocache/service/DownloadService.java + + List> creators = new ArrayList<>(); + Map creator = [ name: ownerDisplayName, type: "Producer" ] + creators.add(creator); + + for (String author : Phylo.getArrayForNewLineAndSemiColonDelimitedValue(phyloInstance.authors)) { + author = author.trim(); + if (StringUtils.isNotEmpty(author)) { + creator = [ name: author, type: "Producer" ] + creators.add(creator); + } + } + + // Doi interface defined at https://doi.ala.org.au/webjars/swagger-ui/2.2.8/index.html?url=/api + Map request = [ + authors: appNameAuthor, + description: description, + applicationUrl: visualisationURL, + licence: getLicences(), + title: title, + userId: phyloInstance.getOwner().getUserId().toString(), + active: true, + provider: grailsApplication.config.doi?.provider?:DEFAULT_DOI_PROVIDER, + customLandingPageUrl: null, + displayTemplate: grailsApplication.config.doi?.displayTemplate?:DEFAULT_DOI_DISPLAY_TEMPLATE, + providerMetadata: [ + title: title, + authors: [ appNameAuthor ], + publisher: appNameAuthor, + creator: creators, + contributors: [ [ name: ownerDisplayName, type: "Distributor" ] ], + descriptions: [ [ text: description, type: "Other" ] ], + resourceText: description, + resourceType: "Text" + ], + applicationMetadata: [ + Title: title, + Authors: appNameAuthor, + Description: description, + "Application Url": visualisationURL, + Creators: creators, + Genus: Phylo.getArrayForNewLineAndSemiColonDelimitedValue(phyloInstance.genus), + Species: Phylo.getArrayForNewLineAndSemiColonDelimitedValue(phyloInstance.species) + ] + ] + + def webServiceApiKey = grailsApplication.config.webservice.apiKey + if (grailsApplication.config.alaDoiUrl_webservice_apiKey) { + grailsApplication.config.webservice.apiKey = grailsApplication.config.alaDoiUrl_webservice_apiKey + } + + def result = webServiceService.postData(url, request, ['Accept':'application/json'], ContentType.APPLICATION_JSON, true, false) + + if (grailsApplication.config.alaDoiUrl_webservice_apiKey) { + grailsApplication.config.webservice.apiKey = webServiceApiKey + } + + if (result == null || !result['doi']) { + throw new Exception("Got an empty response from url=" + url + " with request=" + request) + } + + phyloInstance.doi = result['doi'] + phyloInstance.doiURL = result['landingPage'] + phyloInstance.doiUuid = result['uuid'] + phyloInstance.doiCreationDate = new Date() + + return phyloInstance + } + + def getLicences() { + List licences = new ArrayList<>(); + while (true) { + String licence = grailsApplication.config.doi?.licences."${licences.size() + 1}" + if (licence == null) { + break; + } + licences.add(licence); + } + licences + } +} diff --git a/grails-app/services/au/org/ala/phyloviz/EmailService.groovy b/grails-app/services/au/org/ala/phyloviz/EmailService.groovy new file mode 100644 index 00000000..b17b8f42 --- /dev/null +++ b/grails-app/services/au/org/ala/phyloviz/EmailService.groovy @@ -0,0 +1,27 @@ +package au.org.ala.phyloviz + +import org.springframework.validation.Errors + +class EmailService { + def grailsApplication + + void sendDoiFailureEmail(Object recipient, String sender, String doi, Errors errors) { + sendEmailView(recipient, sender, "Failure Alert - DOI ${doi}", [doi: doi, error: errors], '/emails/doi-failure') + } + + void sendEmailView(Object recipient, String sender, String subjectText, Map model, String htmlView, String textView = null) { + log.debug("Sending email to ${recipient} with subject '${subjectText}'") + + if (recipient) { + sendMail { + to recipient + from sender + subject subjectText + html(view: htmlView, model: model) + if (textView) { + text(view: textView) + } + } + } + } +} diff --git a/grails-app/services/au/org/ala/phyloviz/PhyloService.groovy b/grails-app/services/au/org/ala/phyloviz/PhyloService.groovy index 1e7b29a8..be792c38 100644 --- a/grails-app/services/au/org/ala/phyloviz/PhyloService.groovy +++ b/grails-app/services/au/org/ala/phyloviz/PhyloService.groovy @@ -1,12 +1,23 @@ package au.org.ala.phyloviz import grails.transaction.Transactional +import java.util.Date +import grails.converters.JSON +import grails.gorm.transactions.ReadOnly + +import org.apache.commons.lang3.StringUtils @Transactional class PhyloService { + def grailsApplication def authService - + def treeService + def userService + def doiService + def emailService + def utilsService + void deleteVisualisation(Integer id) { Phylo.findById(id)?.delete() } @@ -18,14 +29,49 @@ class PhyloService { "viz": ['viz':'PhyloJive'], owner: owner ]) + + prepopulateEmptyFields(viz) + viz.save( flush: true ) if(!viz.hasErrors()){ viz.setTitle('My viz #'+viz.getId()) - viz.save(flush: true); + setStatusAndAddWorkflowStatusEntry(viz, Phylo.WorkflowStatus.Draft) } return viz } + def prepopulateEmptyFields(Phylo phyloInstance) { + if (StringUtils.isBlank(phyloInstance.genus) || StringUtils.isBlank(phyloInstance.species)) { + List genusNames = new ArrayList<>(); + List speciesNames = treeService.getSpeciesNamesFromTree(Integer.parseInt(phyloInstance.studyid)) + for (String species : speciesNames) { + String genus = Phylo.parseGenusFromSpecies(species) + if (! isValueInListCaseInsensitive(genus, genusNames)) { + genusNames.add(genus); + } + } + if (StringUtils.isBlank(phyloInstance.genus)) { + phyloInstance.genus = Phylo.listToStringWithNewLineDelimiter(genusNames); + } + if (StringUtils.isBlank(phyloInstance.species)) { + phyloInstance.species = Phylo.listToStringWithNewLineDelimiter(speciesNames) + } + } + + if (phyloInstance.getStatus() == null) { + phyloInstance.setWorkflowStatus(Phylo.WorkflowStatus.Draft); + } + } + + boolean isValueInListCaseInsensitive(String value, List list) { + for (String listValue : list) { + if (value.equalsIgnoreCase(listValue)) { + return true; + } + } + return false; + } + def getDemoId(){ def demo = Phylo.findByTitle('Phylolink Demo'); if(demo){ @@ -33,12 +79,343 @@ class PhyloService { } } - def isAuthorised(Owner owner){ - def userId = authService.getUserId(); - if(owner && owner.getUserId() && userId) { - userId.toString() == owner.getUserId().toString() + def getOwnerOfDemoViz() { + utilsService.guestAccount() + } + + def isDemonstrationViz(Phylo phyloInstance) { + return phyloInstance.id.equals(getDemoId()) || phyloInstance.owner.userId.equals(getOwnerOfDemoViz()); + } + + def isPublished(Phylo phyloInstance) { + return Phylo.WorkflowStatus.Approved.equals(phyloInstance.workflowStatus) + } + + /** + * @param phyloInstance + * @return true if the logged in user is the owner/creator of the phylolink visualisation and the workflow status + * is Draft or Revision Required (or has no status to handle previous visualisations) + */ + def isAuthorised(Phylo phyloInstance){ + if (isOwner(phyloInstance)) { + (phyloInstance.status == null || Phylo.WorkflowStatus.Draft.equals(phyloInstance.workflowStatus) || + Phylo.WorkflowStatus.Revision_Required.equals(phyloInstance.workflowStatus)) } else { false } } + + def isLoggedIn() { + return authService.getUserId() != null; + } + + def isAuthorisedToView(Phylo phyloInstance) { + if (!phyloInstance) { + return false; + } +// return isPublished(phyloInstance) || isOwner(phyloInstance) || isAuthorisedWorkflowAdmin() || +// isDemonstrationViz(phyloInstance); + // Business Rule as agreed with Corinna and Renee: have to be logged in to view any viz apart from the demo viz + return isLoggedIn() || isDemonstrationViz(phyloInstance); + } + + def isOwner(Phylo phyloInstance) { + def Owner owner = phyloInstance.getOwner(); + def userId = authService.getUserId(); + if(owner && owner.getUserId() && userId) { + userId.toString() == owner.getUserId().toString() + } else { + false + } + } + + def isAuthorisedWorkflowAdmin() { + userService.userIsPhylolinkWorkflowAdmin(); + } + + def isAuthorisedToApprove(Phylo phyloInstance) { + isAuthorisedWorkflowAdmin() && + (Phylo.WorkflowStatus.Requested_Approval.equals(phyloInstance.workflowStatus) || isAuthorised(phyloInstance)) + } + + def isAuthorisedToReject(Phylo phyloInstance) { + Phylo.WorkflowStatus.Requested_Approval.equals(phyloInstance.workflowStatus) && (isAuthorisedWorkflowAdmin() || isOwner(phyloInstance)) + } + + def isAuthorisedToReinstate(Phylo phyloInstance) { + isOwner(phyloInstance) && Phylo.WorkflowStatus.No_Longer_Required.equals(phyloInstance.workflowStatus) + } + + def isAuthorisedToPerformStatusTransition(Phylo phyloInstance, Phylo.WorkflowStatus newStatus) throws IllegalArgumentException { + if (phyloInstance.workflowStatus.equals(newStatus)) { + throw new IllegalArgumentException("In isAuthorisedToPerformStatusTransition, newStatus is the same as the current status=" + newStatus) + } + + switch (newStatus) { + case Phylo.WorkflowStatus.Draft: + return isAuthorisedToReinstate(phyloInstance) + + case Phylo.WorkflowStatus.Requested_Approval: + case Phylo.WorkflowStatus.No_Longer_Required: + return isAuthorised(phyloInstance) + + case Phylo.WorkflowStatus.Approved: + return isAuthorisedToApprove(phyloInstance) + + case Phylo.WorkflowStatus.Revision_Required: + return isAuthorisedToReject(phyloInstance) + } + + throw new IllegalArgumentException("In isAuthorisedToPerformStatusTransition, unhandled Phylo.WorkflowStatus=" + newStatus) + } + + public void saveMetadata(Phylo phyloInstance, Object params = null) { + if (! isAuthorised(phyloInstance)) { + throw new IllegalArgumentException("In saveMetadata, user is not authorised to save metadata") + } + phyloInstance.setTitle(params.newTitle); + phyloInstance.setGenus(params.newGenus); + phyloInstance.setAuthors(params.newAuthors); + phyloInstance.setPublishedPapers(params.newPublishedPapers); + phyloInstance.setUnpublishedData(Boolean.valueOf(params.newUnpublishedData)); + phyloInstance.setNotes(params.newNotes); + + def newcharacterDataSetTraitsJSON = JSON.parse(params.newcharacterDataSetTraits) + + for (Object characterDataSetTrait : newcharacterDataSetTraitsJSON) { + CharacterTrait characterTrait = null + for (CharacterTrait thisCharacterTrait : phyloInstance.characterSource.characterTraits) { + if (StringUtils.equalsIgnoreCase(thisCharacterTrait.title, characterDataSetTrait['title'])) { + characterTrait = thisCharacterTrait + break + } + } + if (characterTrait == null) { + characterTrait = new CharacterTrait( [ + characters: phyloInstance.characterSource, + ]) + phyloInstance.characterSource.addToCharacterTraits(characterTrait) + } + characterTrait.setTitle(characterDataSetTrait['title']) + characterTrait.setDescription(characterDataSetTrait['description']) + characterTrait.setTraitType(CharacterTrait.TraitType.forValue(characterDataSetTrait['type'])) + } + + phyloInstance.save(flush: true, failOnError: true) + } + + public Map changeWorkflowStatus(Phylo phyloInstance, Object params, String visualisationURL){ + String responseMessage = ""; + String processDescription = "change status"; + Map response = null; + try { + Phylo.WorkflowStatus newStatus = Phylo.WorkflowStatus.forValue(params.newStatus); + + if (! isAuthorisedToPerformStatusTransition(phyloInstance, newStatus)) { + throw new IllegalStateException("Not authorised to change status from " + phyloInstance.workflowStatus + " to " + newStatus) + } + + if (newStatus.equals(Phylo.WorkflowStatus.Revision_Required) && StringUtils.isBlank(params.workflowComment)) { + throw new IllegalArgumentException("A workflow comment must be provided when changing the status to " + + Phylo.WorkflowStatus.Revision_Required) + } + + if (isAuthorised(phyloInstance)) { + saveMetadata(phyloInstance, params); + } + + Date workflowStatusEntryDate = null + if (! newStatus.equals(Phylo.WorkflowStatus.Approved)) { + workflowStatusEntryDate = setStatusAndAddWorkflowStatusEntry(phyloInstance, newStatus, params, visualisationURL) + + responseMessage = responseMessage + " Successfully changed status."; + } + + if (newStatus.equals(Phylo.WorkflowStatus.Approved) && StringUtils.isBlank(phyloInstance.getDoi())) { + processDescription = "create DOI"; + phyloInstance = doiService.mintDoi(phyloInstance, visualisationURL) + responseMessage = responseMessage + " Successfully created the DOI (" + phyloInstance.doi + ")."; + + processDescription = "save DOI metadata"; + workflowStatusEntryDate = setStatusAndAddWorkflowStatusEntry(phyloInstance, newStatus, params, visualisationURL) + + responseMessage = responseMessage + " Successfully saved DOI metadata."; + } + + response = [ + message: responseMessage, + doi: phyloInstance.doi, + doiURL: phyloInstance.doiURL, + doiCreationDate: phyloInstance.doiCreationDate, + edit: isAuthorised(phyloInstance), + userCanApprove: isAuthorisedToApprove(phyloInstance), + userCanReject: isAuthorisedToReject(phyloInstance), + userCanReinstate: isAuthorisedToReinstate(phyloInstance), + workflowStatusEntryDate: workflowStatusEntryDate + ] + + } catch (Exception e) { + log.error("Exception while trying to " + processDescription + " for Phylo with Id=" + phyloInstance?.getId(), e); + response = [ + message: responseMessage + " Failed to " + processDescription + "." + ] + } + + response + } + + public Date setStatusAndAddWorkflowStatusEntry(Phylo phyloInstance, Phylo.WorkflowStatus newStatus, Object params = null, + String visualisationURL = null) throws IllegalStateException { + + def currentUser = userService.registerCurrentUser(); + + if (currentUser == null) { + throw new IllegalStateException("Must be authenticated to add a workflow comment") + } + + Date workflowStatusEntryDate = phyloInstance.doiCreationDate ?: new Date() + + def workflowStatusEntry = new WorkflowStatusEntry( [ + phylo: phyloInstance, + owner: currentUser, + date: workflowStatusEntryDate, + comment: (params?.workflowComment?:null) + ]) + workflowStatusEntry.setWorkflowStatus(newStatus) + + phyloInstance.addToWorkflowStatusEntries(workflowStatusEntry) + Phylo.WorkflowStatus oldStatus = null + if (params) { + oldStatus = phyloInstance.getWorkflowStatus() + } + phyloInstance.setWorkflowStatus(newStatus) + + if (newStatus.equals(Phylo.WorkflowStatus.Approved) && phyloInstance.characterSource != null) { + phyloInstance.characterSource.setCanDelete(Boolean.FALSE) + } + + phyloInstance.save(flush: true, failOnError: true) + + if (newStatus.equals(Phylo.WorkflowStatus.Approved)) { + def sandbox = Sandbox.get(phyloInstance.source) + sandbox.setCanDelete(Boolean.FALSE) + + sandbox.save(flush: true, failOnError: true) + } + + if (oldStatus) { + sendStatusChangeEmailToAllRecipients(phyloInstance, currentUser, oldStatus, newStatus, visualisationURL) + } + + workflowStatusEntryDate + } + + private sendStatusChangeEmailToAllRecipients(Phylo phyloInstance, Owner currentUser, Phylo.WorkflowStatus oldStatus, + Phylo.WorkflowStatus newStatus, String visualisationURL) { + String sender = null; + Map model = null; + try { + def noreply = grailsApplication.config.getProperty('support.noreply', String, 'no-reply@ala.org.au') + sender = "phylolink <$noreply>" + model = [phyloInstance: phyloInstance, oldStatus: oldStatus, newStatus: newStatus, visualisationURL: visualisationURL] + + if (! isOwner(phyloInstance)) { // Workflow admin who is not the owner has approved or revision required, so let owner know + Boolean actionRequired = ! (newStatus.equals(Phylo.WorkflowStatus.Approved) || newStatus.equals(Phylo.WorkflowStatus.Requested_Approval)) + sendStatusChangeEmail(phyloInstance.owner.email, sender, actionRequired, "Your Phylolink visualisation now has status " + newStatus, + model, '/phylo/emails/status-change-to-creator') + } + + Boolean actionRequired = null + if (newStatus.equals(Phylo.WorkflowStatus.Requested_Approval)) { + actionRequired = Boolean.TRUE + } else if (oldStatus.equals(Phylo.WorkflowStatus.Requested_Approval) && + (newStatus.equals(Phylo.WorkflowStatus.Approved) || newStatus.equals(Phylo.WorkflowStatus.Revision_Required))) { + actionRequired = Boolean.FALSE + } + if (actionRequired != null) { + List recipients = userService.getPhylolinkWorkflowAdminEmailRecipients() + sendStatusChangeEmail(recipients, sender, actionRequired, "Phylolink visualisation with ID " + phyloInstance.id + " now has status " + newStatus, + model, '/phylo/emails/status-change-to-approver') + } + + } catch (Exception ignored) { + log.error("IGNORING exception while trying to sendStatusChangeEmail for status change " + oldStatus + " to " + + newStatus + " for Phylo with Id=" + phyloInstance?.getId(), ignored); + } + } + + private sendStatusChangeEmail(Object recipient, String sender, Boolean actionRequired, String subjectText, Map model, String htmlView) { + try { + if (actionRequired) { + subjectText = "[ACTION REQ] " + subjectText + model['actionRequiredText'] = "log on, then go to the Metadata tab to review and action" + } else { + subjectText = "[FYI] " + subjectText + model['actionRequiredText'] = "view" + } + + emailService.sendEmailView(recipient, sender, subjectText, model, htmlView) + } catch (Exception ignored) { + log.error("IGNORING exception while trying to sendStatusChangeEmail to recipient=" + recipient + " for status change " + + model['oldStatus'] + " to " + model['newStatus'] + " for Phylo with Id=" + model['phyloInstance']?.getId() + + " and htmlView=" + htmlView, ignored); + } + } + + /** + * filter search will return all visualisations with status "Approved and Published" where for each of the words (limited to the first 5) in the search filter, + * the word must be present (case insensitive) in at least one of the following visualisation fields: title, genus, species, authors, publishedPapers, created by, doi + **/ + @ReadOnly + def listPublishedViz(String filter, int pageSize, int startFrom, String sortBy = "doiCreationDate", String sortOrder = "desc") { + Map hqlParams = [ max: pageSize, offset: startFrom ] + + String hql = "from Phylo as p " + + "where p.status = '" + Phylo.WorkflowStatus.Approved.getKey() + "'" + + if (StringUtils.isNotBlank(filter)) { + String[] words = filter.trim().replaceAll("%", "").toLowerCase().split(" ") + int i = 0 + for (String word : words) { + if (StringUtils.isNotBlank(word)) { + i++; + hqlParams.put("word" + i, "%" + word.toLowerCase() + "%"); + hql = hql + " and (lower(p.title) like :word" + i + " or lower(p.genus) like :word" + i + " or lower(p.species) like :word" + i + + " or lower(p.authors) like :word" + i + " or lower(p.publishedPapers) like :word" + i + + " or lower(p.owner.displayName) like :word" + i + " or lower(p.doi) like :word" + i + + ")" + // Only do a maximum of 5 words + if (i == 5) { + break; + } + } + } + } + + def rowCount = Phylo.executeQuery("select count(p) " + hql, hqlParams).get(0) + + hql = hql + " order by p." + sortBy + " " + sortOrder + + log.debug("In listPublishedViz hql='${hql}', hqlParams=${hqlParams}") + + return [list : Phylo.findAll(hql, hqlParams), totalCount : rowCount] + } + + @ReadOnly + def listVizThatRequireMyAttention(String sortBy = "id", String sortOrder = "asc") { + + def userId = authService.getUserId(); + + if (userId == null) { + return new ArrayList(); + } + + String hql = "from Phylo as p " + + "where (p.owner.userId = :userId and p.status = '" + Phylo.WorkflowStatus.Revision_Required.getKey() + "')" + if (isAuthorisedWorkflowAdmin()) { + hql = hql + " or p.status = '" + Phylo.WorkflowStatus.Requested_Approval.getKey() + "'" + } + hql = hql + " order by p." + sortBy + " " + sortOrder + return Phylo.findAll(hql, [userId: Long.valueOf(userId)]) + } } diff --git a/grails-app/services/au/org/ala/phyloviz/SpeciesListService.groovy b/grails-app/services/au/org/ala/phyloviz/SpeciesListService.groovy index 4fc308c5..c81e07c3 100644 --- a/grails-app/services/au/org/ala/phyloviz/SpeciesListService.groovy +++ b/grails-app/services/au/org/ala/phyloviz/SpeciesListService.groovy @@ -64,7 +64,8 @@ class SpeciesListService { def charList = [ 'owner': own, 'title': title, - 'drid' : drid + 'drid' : drid, + 'canDelete': true ] def c = new Characters(charList).save( flush: true diff --git a/grails-app/services/au/org/ala/phyloviz/UserService.groovy b/grails-app/services/au/org/ala/phyloviz/UserService.groovy index fc342b66..66365919 100644 --- a/grails-app/services/au/org/ala/phyloviz/UserService.groovy +++ b/grails-app/services/au/org/ala/phyloviz/UserService.groovy @@ -1,6 +1,8 @@ package au.org.ala.phyloviz +import grails.util.Environment import javax.annotation.PostConstruct +import javax.mail.internet.InternetAddress class UserService { def grailsApplication, authService, webServiceService @@ -36,10 +38,51 @@ class UserService { def userIsSiteAdmin() { return authService.userInRole(grailsApplication.config.security.cas.officerRole?:'ROLE_OFFICER') || - authService.userInRole(grailsApplication.config.security.cas.adminRole?:'ROLE_PHYLOLINK_ADMIN') || + userIsPhylolinkAdmin() || authService.userInRole(grailsApplication.config.security.cas.alaAdminRole?:'ROLE_ADMIN') } + def userIsPhylolinkAdmin() { + return authService.userInRole(grailsApplication.config.security.cas.adminRole?:'ROLE_PHYLOLINK_ADMIN') + } + + def userIsPhylolinkWorkflowAdmin() { + authService.userInRole(getPhylolinkWorkflowAdminRole()) || + (Environment.isDevelopmentMode() && "jasenschremmer@hotmail.com".equals(authService.userDetails()?.email)) + } + + private String getPhylolinkWorkflowAdminRole() { + return grailsApplication.config.security.cas.workflowAdminRole?:'ROLE_PHYLOLINK_ADMIN' + } + + public List getPhylolinkWorkflowAdminEmailRecipients() throws Exception { + List recipients = new ArrayList<>() + try { + def url = grailsApplication.config.security.cas.casServerName + "/userdetails/userdetails/byrole?role=" + getPhylolinkWorkflowAdminRole() + //NOTE: this is secured by IP whitelisting so the external IP of the environment the grails app is running on will need to be added to + // the list of Authorised Services by someone in the development team + def userList = webServiceService.doJsonPost(url, "{}")?.data + if (userList) { + for (user in userList) { + if (! Boolean.valueOf(user.locked)) { + recipients.add(new InternetAddress(user.email, user.firstName + " " + user.lastName).toString()) + } + } + } + log.debug("In getPhylolinkWorkflowAdminEmailRecipients recipients=${recipients}") + + } catch (Exception e) { + if (Environment.isDevelopmentMode()) { + log.error("IGNORING exception while trying to getPhylolinkWorkflowAdminEmailRecipients recipients=" + recipients, ignored) + recipients.add(new InternetAddress("renee.catullo@csiro.au", "Renee Catullo").toString()) + recipients.add(new InternetAddress("jasen.schremmer@anu.edu.au", "O'Schremmer, Jasen").toString()) + } else { + throw e + } + } + return recipients + } + def getRecentEditsForUserId(userId) { def url = auditBaseUrl + "/getRecentEditsForUserId/${userId}" webServiceService.getJson(url) diff --git a/grails-app/services/au/org/ala/phyloviz/WebServiceService.groovy b/grails-app/services/au/org/ala/phyloviz/WebServiceService.groovy index d3ef223b..d8a9d944 100644 --- a/grails-app/services/au/org/ala/phyloviz/WebServiceService.groovy +++ b/grails-app/services/au/org/ala/phyloviz/WebServiceService.groovy @@ -147,8 +147,9 @@ class WebServiceService implements InitializingBean { * @param data - data to post * @return data from post response */ - def postData( String url, body, head = [:], enc = org.apache.http.entity.ContentType.APPLICATION_FORM_URLENCODED ){ - def response = webService.post(url, body, [:], enc, true, true, head) + def postData( String url, body, head = [:], enc = org.apache.http.entity.ContentType.APPLICATION_FORM_URLENCODED, + boolean includeApiKey = true, boolean includeUser = true){ + def response = webService.post(url, body, [:], enc, includeApiKey, includeUser, head) response.resp } diff --git a/grails-app/views/phylo/_character.gsp b/grails-app/views/phylo/_character.gsp index e1866eb6..4bd4983b 100644 --- a/grails-app/views/phylo/_character.gsp +++ b/grails-app/views/phylo/_character.gsp @@ -15,7 +15,9 @@

Upload your own character data. Data should be in CSV format with the - first column being a scientific name. + first column being a species scientific name. For best results please ensure the species scientific name exactly matches the + species name as shown in the tree (please note that underscores in the species names in the tree are replaced with spaces before being + shown, so please remove underscores from your species names in your character data). You can supply any number of additional columns with each column being a trait/character.
You can download an @@ -60,11 +62,11 @@ - -

-
-
+ +
+
+
@@ -95,14 +97,15 @@
+
Description:
diff --git a/grails-app/views/phylo/_doi.gsp b/grails-app/views/phylo/_doi.gsp new file mode 100644 index 00000000..b333be7a --- /dev/null +++ b/grails-app/views/phylo/_doi.gsp @@ -0,0 +1,292 @@ +<%-- Uncomment for doi-service/grails-app/views/doiResolve/_phylolink.gsp + @ page contentType="text/html;charset=UTF-8" %> + + + + + + + + + + %{-- Google Analytics --}% + + + %{--End Google Analytics--}% + + + + + + + +Uncomment for doi-service/grails-app/views/doiResolve/_phylolink.gsp +--%> +
+
+

DOI${doi.doi}

+

${doi.title} + + + +

+
+ + + +
+
+
+
+
+
+
+
+

To access this resource, you can + Go to the source +

+
+
+
+
+
+ +
+
+ Application URL: +
+ +
+
+
+ Date Created: +
+
+ ${doi.dateMinted} +
+
+
+
+ Authors: +
+
+ ${doi.authors} +
+
+
+
+ Description: +
+
+ ${doi.description} +
+
+
+
+ Licence: +
+
+ +
    + +
  • ${licence}
  • +
    +
+
+
+
+
+
+ Genus: +
+
+ +
    + +
  • ${v}
  • +
    +
+
+
+
+
+
+ Species: +
+
+ +
    + +
  1. ${v}
  2. +
    +
+
+
+
+
+
+ Creators: +
+
+ +
    + +
  • ${v.name}
  • +
    +
+
+
+
+ + +
+
+ Landing page: +
+
+ This DOI was registered with an application-specific landing page. View the application landing page. +
+
+
+ + +
+

Admin only fields

+
+
+
+ User Id: +
+
+ ${doi.userId} +
+
+ + +
+ The DOI contains sensitive data. The file can only be accessed by users that has all of the roles below +
+
+
+ Sensitive roles: +
+
+
    + +
  • ${role}
  • +
    +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+<%-- Uncomment for doi-service/grails-app/views/doiResolve/_phylolink.gsp + + +Uncomment for doi-service/grails-app/views/doiResolve/_phylolink.gsp +--%> \ No newline at end of file diff --git a/grails-app/views/phylo/_expert.gsp b/grails-app/views/phylo/_expert.gsp new file mode 100644 index 00000000..77775429 --- /dev/null +++ b/grails-app/views/phylo/_expert.gsp @@ -0,0 +1,305 @@ +
+
+ You must add Occurrences and select a specific dataset, before you can create an expert visualisation. + +
+
+

If you wish to publish this visualisation, please enter the required metadata below.
+ Please note that for personal use, this visualisation is already saved and the metadata below is not required.

+ +

+ +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
Created by${phyloInstance.owner.displayName}
Visualisation DOI
DOI Citation URL
Published on
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+ +
+ +
+
+ +
+
+
+ Character metadata +
+
+ + + + + + + +
Dataset:
+ + + +
+

+ Please provide information on your Character dataset. Any values currently not specified can be loaded from a CSV + file (you can download an + example CSV file here): + +

+
+
+
+
+
+

+ + + + + + + + <%--th>Image + + + + + + + + <%--td> + + +
Character nameDescription*Type*
+ +
+

+
+
+
+ + +
+ + + + + + + + + + + + + + + + + +
DateStatusActioned byComment
${it.date}${it.getWorkflowStatus()}${(userIsWorkflowAdmin || userId.toString().equals(it.owner.userId.toString())) ? it.owner.displayName : 'An Approver'}${it.comment}
+
+
+ +
+ +
+ +
+
+ +
+ All work and data associated with this visualisation is licensed and downloadable under +
    + +
  • ${it}
  • +
    +
+
+ + +
+ Please note that once approved, this visualisation will be published and visible to anyone and can not be changed. +
+
+ +
+ + + + + + +
+
+
+
+
+
+
+ +
+ +
+ +
+
+
+ Occurrence metadata +
+
+ + + + + + + +
Dataset:
+ + +
+
+
+
+ +<%-- Uncomment to test the phylolink doi display template: doi-service/grails-app/views/doiResolve/_phylolink.gsp + g:render template="doi" model="[doi: doi]"/ --%> +
diff --git a/grails-app/views/phylo/_metadata.gsp b/grails-app/views/phylo/_metadata.gsp index f4f76c45..679219f9 100644 --- a/grails-app/views/phylo/_metadata.gsp +++ b/grails-app/views/phylo/_metadata.gsp @@ -1,35 +1,31 @@ -
-
- Tree metadata +
+ Phylogenetic tree metadata
- + - + - + - + diff --git a/grails-app/views/phylo/_occurrence.gsp b/grails-app/views/phylo/_occurrence.gsp index a7e4cb6e..563ced25 100644 --- a/grails-app/views/phylo/_occurrence.gsp +++ b/grails-app/views/phylo/_occurrence.gsp @@ -1,13 +1,15 @@
-
+
Upload your occurrence records

Upload your own occurrence data. Once you upload the dataset, you'll be able - to select this for viewing with the phylogenetic tree and map. + to select this for viewing with the phylogenetic tree and map. For best results please ensure the species name exactly matches the species name as shown + in the tree (please note that underscores in the species names in the tree are replaced with spaces before being shown, so please remove underscores from + your species names in your occurrence data).

Login to enable occurrence upload or, @@ -111,11 +113,11 @@ + optionsCaption:'Please choose..', event:{change: drChanged}, disable: ! edit()" required>
- @@ -144,7 +146,7 @@
diff --git a/grails-app/views/phylo/_title.gsp b/grails-app/views/phylo/_title.gsp index a7cc41c9..5e82747a 100644 --- a/grails-app/views/phylo/_title.gsp +++ b/grails-app/views/phylo/_title.gsp @@ -1,7 +1,7 @@ -
+

+ margin-top: 5px;display: inline-block' data-bind="text: title, attr:{title: (edit()?'Click title to edit it':'')}">  
diff --git a/grails-app/views/phylo/emails/doi-failure.gsp b/grails-app/views/phylo/emails/doi-failure.gsp new file mode 100644 index 00000000..7e0e1635 --- /dev/null +++ b/grails-app/views/phylo/emails/doi-failure.gsp @@ -0,0 +1,8 @@ + + +

An unexpected error occurred after successfully generating the DOI ${doi}.

+

The DOI was generated, but the server failed to save to the local DB. No default landing page will exist for this DOI!

+ +

This is an automated email. Please do not reply.

+ + \ No newline at end of file diff --git a/grails-app/views/phylo/emails/status-change-to-approver.gsp b/grails-app/views/phylo/emails/status-change-to-approver.gsp new file mode 100644 index 00000000..de25d88b --- /dev/null +++ b/grails-app/views/phylo/emails/status-change-to-approver.gsp @@ -0,0 +1,13 @@ + + +

Phylolink visualisation with ID ${phyloInstance.id} has changed status: +

+

+

Please use the link ${visualisationURL} to ${actionRequiredText} this visualisation.

+

This is an automated email. Please do not reply.

+ + \ No newline at end of file diff --git a/grails-app/views/phylo/emails/status-change-to-creator.gsp b/grails-app/views/phylo/emails/status-change-to-creator.gsp new file mode 100644 index 00000000..99202b27 --- /dev/null +++ b/grails-app/views/phylo/emails/status-change-to-creator.gsp @@ -0,0 +1,13 @@ + + +

Your Phylolink visualisation has changed status: +

+

+

Please use the link ${visualisationURL} to ${actionRequiredText} this visualisation.

+

This is an automated email. Please do not reply.

+ + \ No newline at end of file diff --git a/grails-app/views/phylo/show.gsp b/grails-app/views/phylo/show.gsp index cec0f6f4..4738af88 100644 --- a/grails-app/views/phylo/show.gsp +++ b/grails-app/views/phylo/show.gsp @@ -3,17 +3,32 @@ --%> <%@ page import="grails.converters.JSON" contentType="text/html;charset=UTF-8" %> +<%@ page import="groovy.json.StringEscapeUtils" %> +<%@ page import="au.org.ala.phyloviz.DoiService" %> +<%@ page import="au.org.ala.phyloviz.Phylo" %> +<%@ page import="au.org.ala.phyloviz.CharacterTrait" %> + + Login Required | Phylolink + + + ${phyloInstance.title} | Phylolink - + + + + + + + @@ -31,11 +46,14 @@ + + diff --git a/grails-app/views/wizard/pick.gsp b/grails-app/views/wizard/pick.gsp index d93d043d..aafea19c 100644 --- a/grails-app/views/wizard/pick.gsp +++ b/grails-app/views/wizard/pick.gsp @@ -12,35 +12,47 @@
-

Load a tree or select a visualisation

-

On this page, you can load a tree for visualisation, view your previous visualisations, or - upload your own tree for visualisation. You can select a tree from an expert recommended list or from your previous uploads. +

Select or create a visualisation

+

On this page, you can view published visualisations, view your previous visualisations, or + create a new visualisation. When creating a new visualisation, you can upload your own tree for visualisation, + or select a tree: from your previous tree uploads or from a recommended list. + +

+ +
-
-

Load a tree

-
- - - - -
-
-
+

Select a visualisation

+ + type="radio" name="options" value="myViz" required=""> From my previous visualisations (login required)
+
+

Create a visualisation with

+
+ + + + +
+

Administration

diff --git a/grails-app/views/wizard/published.gsp b/grails-app/views/wizard/published.gsp new file mode 100644 index 00000000..e9d086d7 --- /dev/null +++ b/grails-app/views/wizard/published.gsp @@ -0,0 +1,110 @@ +<%-- + Created by Jasen Schremmer on 10/10/2019. +--%> + +<%@ page contentType="text/html;charset=UTF-8" %> +<%@ page import="au.org.ala.phyloviz.Phylo" %> + + + + Published Visualisations + + + + + + +
+

Published Visualisations

+ + +
+ +
+
+
+
+ +
+
+
+
+ + +
+
+ + + Filter + +
+ +

Use the search filter to refine the display results below.
+ Each word in the search must exist in at least one of: title, genus, species, created by or doi

+
+
+ +
+
+ +
+
Title:Title: ${tree.getTitle()}
Reference:Publication citation: ${tree.getReference()}
Year:Publication Year: ${tree.getYear()}
Doi:Publication DOI: - - ${tree.getDoi()} - - + ${tree.getDoi()}
- +
+ + + + + + + + + + + + + + + + +
Published onGenusVisualisation
+ + + +
${g} +
+
+ +
+
+
+
+ +
Showing ${Math.min(viz.totalCount, offset + 1)} to ${Math.min(viz.totalCount, offset + pageSize)} of ${viz.totalCount}
+ +
+ + +
+ +
+
+ +
Back
+ + + \ No newline at end of file diff --git a/grails-app/views/wizard/vizThatNeedMyAttention.gsp b/grails-app/views/wizard/vizThatNeedMyAttention.gsp new file mode 100644 index 00000000..27faac2a --- /dev/null +++ b/grails-app/views/wizard/vizThatNeedMyAttention.gsp @@ -0,0 +1,51 @@ +<%-- + Created by Jasen Schremmer on 19/10/2019. +--%> + +<%@ page contentType="text/html;charset=UTF-8" %> + + + + Visualisations that require your attention + + + + + +
+

Visualisations that require your attention

+ +

List of all visualisations that require your attention

+ + + + + + + + + + + + + + + +
VisualisationStatus
+
+ +
+
+ ${v.getWorkflowStatus()} +
+
+ +

Currently there are no visualisations that require your attention

+
+
Back
+
+ + \ No newline at end of file diff --git a/src/main/groovy/au/org/ala/phyloviz/ConvertSandbox.groovy b/src/main/groovy/au/org/ala/phyloviz/ConvertSandbox.groovy index 79e82d2e..7c27ec63 100644 --- a/src/main/groovy/au/org/ala/phyloviz/ConvertSandbox.groovy +++ b/src/main/groovy/au/org/ala/phyloviz/ConvertSandbox.groovy @@ -13,7 +13,8 @@ class ConvertSandbox extends au.org.ala.phyloviz.ConvertDomainObject { 'title' : 'title', 'biocacheServiceUrl': 'biocacheServiceUrl', 'biocacheHubUrl': 'biocacheHubUrl', - 'dateCreated': 'dateCreated' + 'dateCreated': 'dateCreated', + 'canDelete': 'canDelete' ] } diff --git a/src/main/webapp/artifacts/occurrenceRecords.csv b/src/main/resources/public/artifacts/occurrenceRecords.csv similarity index 100% rename from src/main/webapp/artifacts/occurrenceRecords.csv rename to src/main/resources/public/artifacts/occurrenceRecords.csv diff --git a/src/main/webapp/artifacts/traits.csv b/src/main/resources/public/artifacts/traits.csv similarity index 100% rename from src/main/webapp/artifacts/traits.csv rename to src/main/resources/public/artifacts/traits.csv diff --git a/src/main/resources/public/artifacts/traits_metadata.csv b/src/main/resources/public/artifacts/traits_metadata.csv new file mode 100644 index 00000000..82f0274a --- /dev/null +++ b/src/main/resources/public/artifacts/traits_metadata.csv @@ -0,0 +1,26 @@ +character,character_description,type +morph_SVL,snout-vent length in mm,Integer +morph_head_width,width of head at widest point in mm,Integer +morph_head_length,length of head from tip to front of ear in mm,Integer +morph_hind_leg_length,hind leg length from angle of leg to centre of foot in mm,Integer +trunk_length,length of trunk from front leg to back leg in mm,Integer +trunk_width,width of trunk at the front legs in mm,Integer +foreleg_length,foreleg length from angle of foreleg to centre of palm in mm,Integer +femur_length,length of femur in mm,Integer +head_depth,depth of head behind the eyes in mm,Integer +snout_length,length of snout from front corner of eye to tip of snout in mm,Integer +snout_depth,depth of snout in front of eyes in mm,Integer +naris_to_eye,distance between front corner of eye and closest nostril in mm,Integer +eye_to_ear,distance between rear corner of eye and centre of ear in mm,Integer +internarial_dist,distance between nostrils in mm,Integer +interorbital_dist,distance between front corners of eyes in mm,Integer +orbit_length,length of eye in mm,Integer +rostral_scale_height,size of rostral scale in the vertical axis in mm,Integer +rostral_scale_width,size of rostral scale in the horizontal axis in mm,Integer +supralabial_scales,number of supralabial scales - scales above mouth,Integer +infralabial_scales,number of infralabial scales - scale below the mouth,Integer +internarial_scales,number of scales between nostrils,Integer +toe_length,length of longest toe in mm,Integer +number_toe_lamellae,number of lamellae (plate-like structures) under the longest toe,Integer +division_type,whether the toe lamellae are divided or not divided,Categorical +number_finger_lamellae,number of lamellae (plate-like structures) under the longest finger,Integer diff --git a/src/test/groovy/au/org/ala/phyloviz/PhyloTest.groovy b/src/test/groovy/au/org/ala/phyloviz/PhyloTest.groovy new file mode 100644 index 00000000..cb9d7f58 --- /dev/null +++ b/src/test/groovy/au/org/ala/phyloviz/PhyloTest.groovy @@ -0,0 +1,27 @@ +package au.org.ala.phyloviz + +import java.util.List + +class PhyloTest extends GroovyTestCase { + + void testParseGenusFromSpecies() { + assertToString(Phylo.parseGenusFromSpecies("A B"), "A") + assertToString(Phylo.parseGenusFromSpecies("AB"), "AB") + assertToString(Phylo.parseGenusFromSpecies("A B_C"), "A") + assertToString(Phylo.parseGenusFromSpecies("A_B C"), "A") + assertToString(Phylo.parseGenusFromSpecies("A B C"), "A") + assertToString(Phylo.parseGenusFromSpecies("Gehyra_xenopus"), "Gehyra") + assertToString(Phylo.parseGenusFromSpecies("Gehyra xenopus"), "Gehyra") + assertToString(Phylo.parseGenusFromSpecies("Gehyra-xenopus"), "Gehyra-xenopus") + } + + void testListToStringWithNewLineDelimiter() { + List list = new ArrayList<>() + assertToString(Phylo.listToStringWithNewLineDelimiter(list), "") + list.add("1") + assertToString(Phylo.listToStringWithNewLineDelimiter(list), "1") + list.add("2") + assertToString(Phylo.listToStringWithNewLineDelimiter(list), "1\r\n2") + assertEquals(4, Phylo.listToStringWithNewLineDelimiter(list).length()) + } +} From 696c6d87fdfc97e69efef2be3091041f2f0a8e43 Mon Sep 17 00:00:00 2001 From: jasensch Date: Fri, 8 Nov 2019 08:28:33 +1100 Subject: [PATCH 3/3] Issue 209: Ability to save visualisation as package and mint DOI for package (#209); In build.gradle commented out the windows specific logic, as when doing a "grails assemble" on windows, I didn't want the pathingJarForWindows logic to run. So devs will have to manually uncommented when doing "grails run-app -port=8090" on windows; In CharactersService.getCharacterListsByOwner, handle when initCharacterResourceId is not an existing id in the database; In UserService.getPhylolinkWorkflowAdminEmailRecipients, fix the case of the characters in the URL and email support@ala.org.au when not in development mode if the code is unable to determine the Phylolink Workflow Admin Email Recipients; --- build.gradle | 38 ++++++++++--------- .../org/ala/phyloviz/CharactersService.groovy | 5 ++- .../au/org/ala/phyloviz/UserService.groovy | 6 +-- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/build.gradle b/build.gradle index 91ed3a1a..c0f842aa 100644 --- a/build.gradle +++ b/build.gradle @@ -97,25 +97,29 @@ dependencies { // Based on https://github.com/gradle/gradle/issues/1989 and https://tuhrig.de/gradles-bootrun-and-windows-command-length-limit/ and // Enhanced to handle spaces in the paths as described on https://stackoverflow.com/questions/18659774/how-do-i-handle-files-with-spaces-in-the-classpath-in-manifest-mf -task pathingJarForWindows(type: Jar) { - dependsOn configurations.runtime - appendix = 'pathing' - - doFirst { - manifest { - attributes "Class-Path": configurations.runtime.files.collect { - it.toURL().toString().replaceAll(' ', '%20') //.replaceFirst(/file:/+/, '/') - }.join(' ') - } - } -} +// Uncomment for Windows - Start +//task pathingJarForWindows(type: Jar) { +// dependsOn configurations.runtime +// appendix = 'pathing' +// +// doFirst { +// manifest { +// attributes "Class-Path": configurations.runtime.files.collect { +// it.toURL().toString().replaceAll(' ', '%20') //.replaceFirst(/file:/+/, '/') +// }.join(' ') +// } +// } +//} +// Uncomment for Windows - End bootRun { - dependsOn pathingJarForWindows - doFirst { - logger.error('pathingJarForWindows.archivePath=' + pathingJarForWindows.archivePath) - classpath = files("$buildDir/classes/main", "$buildDir/resources/main", pathingJarForWindows.archivePath) - } +// Uncomment for Windows - Start +// dependsOn pathingJarForWindows +// doFirst { +// logger.error('pathingJarForWindows.archivePath=' + pathingJarForWindows.archivePath) +// classpath = files("$buildDir/classes/main", "$buildDir/resources/main", pathingJarForWindows.archivePath) +// } +// Uncomment for Windows - End jvmArgs('-Dspring.output.ansi.enabled=always') addResources = true } diff --git a/grails-app/services/au/org/ala/phyloviz/CharactersService.groovy b/grails-app/services/au/org/ala/phyloviz/CharactersService.groovy index 04bf7f8a..681cb0d2 100644 --- a/grails-app/services/au/org/ala/phyloviz/CharactersService.groovy +++ b/grails-app/services/au/org/ala/phyloviz/CharactersService.groovy @@ -32,7 +32,10 @@ class CharactersService { } } if (!found) { - lists.add(Characters.findById(initCharacterResourceId)) + Characters characters = Characters.findById(initCharacterResourceId); + if (characters != null) { + lists.add(characters) + } } } getCharUrl(lists); diff --git a/grails-app/services/au/org/ala/phyloviz/UserService.groovy b/grails-app/services/au/org/ala/phyloviz/UserService.groovy index 66365919..34761bd6 100644 --- a/grails-app/services/au/org/ala/phyloviz/UserService.groovy +++ b/grails-app/services/au/org/ala/phyloviz/UserService.groovy @@ -58,7 +58,7 @@ class UserService { public List getPhylolinkWorkflowAdminEmailRecipients() throws Exception { List recipients = new ArrayList<>() try { - def url = grailsApplication.config.security.cas.casServerName + "/userdetails/userdetails/byrole?role=" + getPhylolinkWorkflowAdminRole() + def url = grailsApplication.config.security.cas.casServerName + "/userdetails/userDetails/byRole?role=" + getPhylolinkWorkflowAdminRole() //NOTE: this is secured by IP whitelisting so the external IP of the environment the grails app is running on will need to be added to // the list of Authorised Services by someone in the development team def userList = webServiceService.doJsonPost(url, "{}")?.data @@ -73,12 +73,12 @@ class UserService { } catch (Exception e) { if (Environment.isDevelopmentMode()) { - log.error("IGNORING exception while trying to getPhylolinkWorkflowAdminEmailRecipients recipients=" + recipients, ignored) recipients.add(new InternetAddress("renee.catullo@csiro.au", "Renee Catullo").toString()) recipients.add(new InternetAddress("jasen.schremmer@anu.edu.au", "O'Schremmer, Jasen").toString()) } else { - throw e + recipients.add(new InternetAddress("support@ala.org.au", "ALA Support - Error in Phylolink Logs").toString()) } + log.error("IGNORING exception while trying to getPhylolinkWorkflowAdminEmailRecipients and sending to these recipients instead=" + recipients, e) } return recipients }
- @@ -145,7 +148,7 @@ - + @@ -191,6 +194,7 @@