diff --git a/.gitignore b/.gitignore index 74f562e6a..5f498a78c 100644 --- a/.gitignore +++ b/.gitignore @@ -83,4 +83,4 @@ local.properties # TeXlipse plugin .texlipse - +logs diff --git a/.travis.yml b/.travis.yml index b859b2341..8687c5d38 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,13 +17,23 @@ before_install: - gvm install grails $GRAILS_VERSION || true before_script: - mkdir -p ~/.grails; wget -q -O ~/.grails/settings.groovy https://raw.githubusercontent.com/AtlasOfLivingAustralia/travis-build-configuration/master/travis_grails_settings_new.groovy +- APP_VERSION=`grep '^app\.version=' ./application.properties | sed -e 's/^app\.version=//g'` - MAVEN_REPO="ala-repo-snapshot"; grep '^app\.version=' ./application.properties | grep -q "\-SNAPSHOT"; if [ "$?" = "1" ]; then MAVEN_REPO="ala-repo-release"; fi; script: -- grails clean && grails refresh-dependencies --non-interactive && grails prod war --non-interactive +- grails clean && grails refresh-dependencies --non-interactive && grails prod war + --non-interactive after_success: -- '[ "${TRAVIS_PULL_REQUEST}" = "false" ] && grails prod maven-deploy --repository=$MAVEN_REPO --non-interactive' +- '[ "${TRAVIS_PULL_REQUEST}" = "false" ] && travis_retry grails prod maven-deploy + --repository=$MAVEN_REPO --non-interactive' +- '[ "${TRAVIS_BRANCH}" = "develop" ] && travis_retry curl -X POST --header "X-DEPLOY-KEY: + ${DEPLOY_KEY}" https://volunteer-dev.ala.org.au/deploy/${APP_VERSION}' env: global: - secure: 2MEDHHQ3nxNwf+YGgtC/GXx6kb0y4ixYA7Ia50pZHaN8xMHYdQ8EymKZJ8F9SXw0Feg9FsDc5I90lBJB8URYZZ4hPZN9+uj9crOvnOFMByvJpPikrQ6Yw8IdUjmYxHO/zv+kmOkqVnu6zCtS42olSM7ljeZs0PzW484Ci9w5eM4= - secure: iR4/BuaBNTKIGQENUdQQjzqhUgefvJnfyC0aK0j9NNLVwcH6lE//TAqz22n7TuzTDPq7My+0clua8DEbJt2k7/kMrbrAohCEXtWvI2pBa43GmB+D5/qOW0+MZk46QJ0pR+hmjHZ3U9DjhuNuF3w7zlNsUnItk70FlzV+sRrYgwg= + - secure: pOWY7dwZRDcgsrYcwscdXHNPfhsPOSGhKNrG0G7AA4mhxaRKd9+1D31i5lhiA9qvbA0/lqD7A8Fnzvsf99MkUC6CSCQOh5n104UalYyiRQ/vhwfc0l9HdlCH86hShYq4PKvXA0hK5iCcIdltzn8pSnM4uHQiVKlbEJiX/qYNx7E= +notifications: + hipchat: + rooms: + secure: GV/Ckk/00dAExEE4A2bb/6yuwIdTn6EQ63cdKOH0CSLYiUspolHbueuB/2bGVy1ErmL3+l6A/bwZidnGoiR6N3Id/SWRbXBnxn9s7VRFVPsoKLviJ1Xku2rMf/fFW+/PTm2FtEeGiGlvFcbzuIv+b6Vplm8ixIyosFdsEIB2mqc= diff --git a/TRY-DJB-volunteer-bootstrap-grailsPlugins.iml b/TRY-DJB-volunteer-bootstrap-grailsPlugins.iml deleted file mode 100644 index 0069e9897..000000000 --- a/TRY-DJB-volunteer-bootstrap-grailsPlugins.iml +++ /dev/null @@ -1,136 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/TRY-DJB-volunteer-bootstrap.iml b/TRY-DJB-volunteer-bootstrap.iml deleted file mode 100644 index dad45ae25..000000000 --- a/TRY-DJB-volunteer-bootstrap.iml +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - file://$MODULE_DIR$/web-app/WEB-INF/applicationContext.xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/application.properties b/application.properties index a932b7abd..6fe3b3539 100644 --- a/application.properties +++ b/application.properties @@ -1,9 +1,9 @@ #Grails Metadata file -#Fri Jan 30 17:11:13 EST 2015 +#Tue Feb 03 10:21:13 EST 2015 app.buildDate=29 Jan, 2015 app.buildNumber=192 app.buildProfile=development app.grails.version=2.3.11 app.name=volunteer-portal app.servlet.version=2.5 -app.version=2.0.1 +app.version=2.1.0 diff --git a/grails-app/conf/ApplicationResources.groovy b/grails-app/conf/ApplicationResources.groovy index 231c159cf..22a97e39e 100644 --- a/grails-app/conf/ApplicationResources.groovy +++ b/grails-app/conf/ApplicationResources.groovy @@ -93,4 +93,53 @@ modules = { resource url: 'js/slickgrid/slick.grid.css' } + "greyscale" { + dependsOn "jquery" + resource url: 'css/grey/1.4.2/gray.min.css' + resource url: 'js/grey/1.4.2/jquery.gray.min.js' + } + + "bootbox" { + dependsOn "bootstrap-js, jquery" + resource url: 'js/bootbox/3.3.0/bootbox.js' + } + + "labelAutocomplete" { + dependsOn "bootstrap-js, jquery" + resource url: 'js/label.autocomplete.js' + resource url: 'css/label.autocomplete.css' + } + + "codemirror" { + resource url: 'js/codemirror/5.0/codemirror.css' + resource url: 'js/codemirror/5.0/codemirror.js' + } + + "codemirror-codeedit" { + dependsOn "codemirror" + resource url: "js/codemirror/5.0/addon/edit/matchbrackets.js" + resource url: "js/codemirror/5.0/addon/edit/closebrackets.js" + resource url: "js/codemirror/5.0/addon/comment/continuecomment.js" + resource url: "js/codemirror/5.0/addon/comment/comment.js" + } + + "codemirror-json" { + dependsOn "codemirror" + resource url: 'js/codemirror/5.0/mode/javascript/javascript.js' + } + + "codemirror-groovy" { + dependsOn "codemirror" + resource url: 'js/codemirror/5.0/mode/groovy/groovy.js' + } + + "codemirror-sublime" { + dependsOn "codemirror" + resource url: 'js/codemirror/5.0/keymap/sublime.js' + } + + "codemirror-monokai" { + dependsOn "codemirror" + resource url: 'js/codemirror/5.0/theme/monokai.css' + } } \ No newline at end of file diff --git a/grails-app/conf/BootStrap.groovy b/grails-app/conf/BootStrap.groovy index a6a01fb50..4f6af22bb 100644 --- a/grails-app/conf/BootStrap.groovy +++ b/grails-app/conf/BootStrap.groovy @@ -1,8 +1,13 @@ import au.org.ala.volunteer.* +import com.google.common.io.Resources +import grails.converters.JSON +import groovy.json.JsonSlurper import groovy.sql.Sql import org.apache.commons.lang.StringUtils import org.apache.commons.lang3.time.StopWatch import org.codehaus.groovy.grails.commons.ApplicationHolder +import org.codehaus.groovy.grails.web.json.JSONArray +import org.codehaus.groovy.grails.web.json.JSONObject import org.hibernate.FlushMode class BootStrap { @@ -32,6 +37,8 @@ class BootStrap { fixTaskLastViews(); + prepareDefaultLabels(); + // add system user if (!User.findByUserId('system')) { User u = new User(userId: 'system', email: ' support@ala.org.au', displayName: 'System User') @@ -164,12 +171,12 @@ class BootStrap { } private void fixTaskLastViews() { - logService.log("Checking task last views...") + log.info("Checking task last views...") def taskIds = Task.executeQuery("select t.id, count(vt.id) from Task t left outer join t.viewedTasks vt where t.lastViewed is null group by t.id having count(vt.id) > 0") if (taskIds) { - logService.log("Fixing last view for ${taskIds.size()} tasks...") + log.info("Fixing last view for ${taskIds.size()} tasks...") sessionFactory.currentSession.setFlushMode(FlushMode.MANUAL) try { int count = 0 @@ -186,7 +193,7 @@ class BootStrap { task.lastViewed = lastView.lastView task.lastViewedBy = lastView.userId } else { - logService.log("Problem fixing last view for task ${task.id} - no last view found.") + log.info("Problem fixing last view for task ${task.id} - no last view found.") } count++ if (count % 1000 == 0) { @@ -195,22 +202,22 @@ class BootStrap { sessionFactory.currentSession.clear() } } - logService.log("${count} tasks processed (complete).") + log.info("${count} tasks processed (complete).") } finally { sessionFactory.currentSession.setFlushMode(FlushMode.AUTO) } } else { - logService.log("No tasks with inconsistent last view details.") + log.info("No tasks with inconsistent last view details.") } } private void prepareProjectTypes() { - logService.log("Checking project types...") + log.info("Checking project types...") def builtIns = [[name:'specimens', label:'Specimens', icon:'/images/icon_specimens.png'], [name:'fieldnotes', label: 'Field notes', icon:'/images/icon_fieldnotes.png']] builtIns.each { def projectType = ProjectType.findByName(it.name) if (!projectType) { - logService.log("Creating project type ${it.name}") + log.info("Creating project type ${it.name}") projectType = new ProjectType(name: it.name, label: it.label) } @@ -224,7 +231,7 @@ class BootStrap { } private void prepareValidationRules() { - logService.log("Initialising validation rules") + log.info("Initialising validation rules") checkOrCreateRule('mandatory', '.+', 'This field value is mandatory', "Mandatory fields must have a value supplied to them", true) checkOrCreateRule('numeric', '^[-+]?[0-9]*\\.?[0-9]+$', 'This field must be a number', "Field values must be numeric (floating point or otherwise)", false) checkOrCreateRule('integer', '^[-+]?[0-9]+$', 'This field must be a integer', "Field values must be integers", false) @@ -235,11 +242,11 @@ class BootStrap { private void checkOrCreateRule(String name, String expression, String message, String description, Boolean testEmptyValues = false) { def rule = ValidationRule.findByName(name) if (!rule) { - logService.log("Creating default validation rule '${name}'.") + log.info("Creating default validation rule '${name}'.") rule = new ValidationRule(name:name, regularExpression: expression, message: message, description: description, testEmptyValues: testEmptyValues) rule.save(failOnError: true) } else { - logService.log("Validation rule '${name}' exists.") + log.info("Validation rule '${name}' exists.") } } @@ -309,12 +316,12 @@ class BootStrap { private void preparePickLists() { // add some picklist values if not already loaded - logService.log "creating picklists..." + log.info "creating picklists..." def items = ["country", "stateProvince", "typeStatus", "institutionCode", "recordedBy", "verbatimLocality", "coordinateUncertaintyInMeters"] items.each { - logService.log("checking picklist: " + it) + log.info("checking picklist: " + it) if (!Picklist.findByName(it)) { - logService.log("creating new picklist " + it) + log.info("creating new picklist " + it) Picklist picklist = new Picklist(name: it).save(flush: true, failOnError: true) def csvText = ApplicationHolder.application.parentContext.getResource("classpath:resources/" + it + ".csv").inputStream.text csvText.eachCsvLine { tokens -> @@ -333,4 +340,26 @@ class BootStrap { } + private void prepareDefaultLabels() { + log.info("Preparing default labels") + final prop = grailsApplication.config.bvp.labels.ensureDefault + final shouldInstall = prop.asBoolean() + if (shouldInstall) { + log.info("Installing default labels") + final defaults = (JSONObject)JSON.parse(Resources.getResource('default-labels.json').newReader()) + //final defaultsSet = defaults.keySet().collectEntries { [(it): defaults[it].toSet() ] } + final labels = Label.all // TODO scroll? + final labelSet = labels.toSet() + final newLabels = defaults.keySet().collect { k -> + final a = (JSONArray)defaults[k] + a.collect { new Label(category: k, value: it) }.findAll { !labelSet.contains(it) } + }.flatten() + log.info("Adding ${newLabels.size()} new labels") + log.debug("Adding ${newLabels.join('\n')}") + if (newLabels) Label.saveAll(newLabels) + } else { + log.debug("Skipping default labels") + } + } + } diff --git a/grails-app/conf/BuildConfig.groovy b/grails-app/conf/BuildConfig.groovy index 985175269..257f3402e 100644 --- a/grails-app/conf/BuildConfig.groovy +++ b/grails-app/conf/BuildConfig.groovy @@ -43,7 +43,7 @@ grails.project.dependency.resolution = { runtime ":csv:0.3.1" runtime ":executor:0.3" compile ":markdown:1.1.1" - runtime ":pretty-time:0.3" + runtime ":pretty-time:2.1.3.Final-1.0.1" runtime ":quartz:1.0.1" runtime ":tiny-mce:3.4.9" runtime ":webxml:1.4.1" @@ -55,6 +55,9 @@ grails.project.dependency.resolution = { excludes 'javassist' } compile ':scaffolding:2.0.3' + compile ':build-info:1.2.8' + compile ":yammer-metrics:3.0.1-2" + compile ":grails-melody:1.55.0" } dependencies { diff --git a/grails-app/conf/Config.groovy b/grails-app/conf/Config.groovy index 8d52eabf7..531a7e6d6 100644 --- a/grails-app/conf/Config.groovy +++ b/grails-app/conf/Config.groovy @@ -34,6 +34,7 @@ bvp.user.activity.monitor.enabled = true // can turn off activity monitoring bvp.user.activity.monitor.timeout = 3600 // seconds bvp.users.migrateIds = false +bvp.labels.ensureDefault = true /******************************************************************************\ * EXTERNAL SERVERS @@ -150,7 +151,7 @@ bie.searchPath = "/search" headerAndFooter.baseURL = "http://www2.ala.org.au/commonui" grails.mime.file.extensions = true // enables the parsing of file extensions from URLs into the request format -grails.mime.use.accept.header = false +grails.mime.use.accept.header = true grails.mime.types = [ html: ['text/html','application/xhtml+xml'], xml: ['text/xml', 'application/xml'], text: 'text/plain', @@ -189,6 +190,8 @@ grails.spring.bean.packages = [] // request parameters to mask when logging exceptions grails.exceptionresolver.params.exclude = ['password'] +bvp.tmpdir="/data/${appName}/config/" + // set per-environment serverURL stem for creating absolute links environments { development { @@ -222,6 +225,11 @@ environments { } +metrics { + // servletUrlPattern = '/admin/metrics/*' + servletEnabled = true +} + environments { development { grails.mail.disabled = true @@ -240,7 +248,6 @@ environments { } grails.mail.default.from="support@ala.org.au" - grails { cache { enabled = true @@ -271,32 +278,57 @@ grails { //hibernate.type="trace,stdout" // log4j configuration +def loggingDir = (System.getProperty('catalina.base') ? System.getProperty('catalina.base') + '/logs' : './logs') log4j = { // Example of changing the log pattern for the default console // appender: // appenders { - console name:'stdout', layout:pattern(conversionPattern: '%-5p [%c{2}] %m%n') + environments { + production { + rollingFile name: "tomcatLog", maxFileSize: '10MB', file: "${loggingDir}/${appName}.log", layout: pattern(conversionPattern: "%d %-5p [%c{1}] %m%n")//, threshold: Level.INFO + rollingFile name: "access", maxFileSize: '10MB', file: "${loggingDir}/${appName}-session-access.log", layout: pattern(conversionPattern: "%d %m%n")//, threshold: Level.INFO + } + development { + console name: "tomcatLog", layout: pattern(conversionPattern: "%d %-5p [%c{1}] %m%n")//, threshold: Level.DEBUG + console name: "access", layout: pattern(conversionPattern: "%d %m%n")//, threshold: Level.DEBUG + } + test { + rollingFile name: "tomcatLog", maxFileSize: '1MB', file: "/tmp/${appName}", layout: pattern(conversionPattern: "%d %-5p [%c{1}] %m%n")//, threshold: Level.DEBUG + rollingFile name: "access", maxFileSize: '10MB', file: "/tmp/${appName}-session-access.log", layout: pattern(conversionPattern: "%d %m%n")//, threshold: Level.DEBUG + } + } + } + + root { + error 'tomcatLog' } - error 'org.codehaus.groovy.grails.web.servlet', // controllers - 'org.codehaus.groovy.grails.web.pages', // GSP - 'org.codehaus.groovy.grails.web.sitemesh', // layouts - 'org.codehaus.groovy.grails.web.mapping.filter', // URL mapping - 'org.codehaus.groovy.grails.web.mapping', // URL mapping - 'org.codehaus.groovy.grails.commons', // core / classloading - 'org.codehaus.groovy.grails.plugins', // plugins - 'org.codehaus.groovy.grails.orm.hibernate', // hibernate integration - 'org.springframework', - 'org.hibernate', - 'net.sf.ehcache.hibernate', - 'grails.app' - warn 'org.mortbay.log', - 'grails.app' - info 'grails.app' - warn 'grails.plugin.mail' - - warn 'au.org.ala.cas.client','au.org.ala.cas.util' + info additivity: false, + access: ["au.org.ala.volunteer.BVPServletFilter", + "au.org.ala.volunteer.BVPSessionListener"] + + + error 'org.codehaus.groovy.grails.web.servlet', // controllers + 'org.codehaus.groovy.grails.web.pages', // GSP + 'org.codehaus.groovy.grails.web.sitemesh', // layouts + 'org.codehaus.groovy.grails.web.mapping.filter', // URL mapping + 'org.codehaus.groovy.grails.web.mapping', // URL mapping + 'org.codehaus.groovy.grails.commons', // core / classloading + 'org.codehaus.groovy.grails.plugins', // plugins + 'org.codehaus.groovy.grails.orm.hibernate', // hibernate integration + 'org.springframework', + 'org.hibernate', + 'net.sf.ehcache.hibernate' + + warn 'org.mortbay.log', + 'grails.plugin.mail', + 'au.org.ala.cas.client', + 'au.org.ala.cas.util' + + info 'grails.app', + 'au.org.ala' + } // Uncomment and edit the following lines to start using Grails encoding & escaping improvements diff --git a/grails-app/conf/GrailsMelodyConfig.groovy b/grails-app/conf/GrailsMelodyConfig.groovy new file mode 100644 index 000000000..a55443247 --- /dev/null +++ b/grails-app/conf/GrailsMelodyConfig.groovy @@ -0,0 +1,34 @@ +/* +You can find all detailed parameter usage from +http://code.google.com/p/javamelody/wiki/UserGuide#6._Optional_parameters +Any parameter with 'javamelody.' prefix configured in this file will be add as init-param of java melody MonitoringFilter. + */ + +/* +The parameter disabled (false by default) just disables the monitoring. + */ +//javamelody.disabled = false + +/* +The parameter system-actions-enabled (true by default) enables some system actions. + */ +//javamelody.'system-actions-enabled' = true + +/* +Turn on Grails Service monitoring by adding 'spring' in displayed-counters parameter. + */ +javamelody.'displayed-counters' = 'http,sql,error,log,spring,jsp' + +/* +The parameter url-exclude-pattern is a regular expression to exclude some urls from monitoring as written above. + */ +//javamelody.'url-exclude-pattern' = '/static/.*' + +/* +Specify jndi name of datasource to monitor in production environment + */ +/*environments { + production { + javamelody.datasources = 'java:comp/env/myapp/mydatasource' + } +}*/ diff --git a/grails-app/conf/UrlMappings.groovy b/grails-app/conf/UrlMappings.groovy index 53fd23e78..055ec221c 100644 --- a/grails-app/conf/UrlMappings.groovy +++ b/grails-app/conf/UrlMappings.groovy @@ -8,7 +8,9 @@ class UrlMappings { } } + "/admin/label/$action?" (controller: 'label') "/admin/leaderboard/$action?" (controller: 'leaderBoardAdmin') + "/admin/achievements/$action?/$id?" (controller: 'achievementDescription') "/$controller/$action?/$id?"{ constraints { diff --git a/grails-app/conf/WebXmlConfig.groovy b/grails-app/conf/WebXmlConfig.groovy new file mode 100644 index 000000000..7e2298d7d --- /dev/null +++ b/grails-app/conf/WebXmlConfig.groovy @@ -0,0 +1,55 @@ +/** + * Application configuration file for WebXml plugin. + */ +webxml { + //======================================== + // Session Timeout + //======================================== + // + // uncomment to set session timeout - Be sure to specify value as an Integer + // sessionConfig.sessionTimeout = 30 + + //======================================== + // Delegating Filter Chain + //======================================== + // + // Add a 'filter chain proxy' delegater as a Filter. This will allow the application + // to define a FilterChainProxy bean that can add additional filters, such as + // an instance of org.springframework.security.web.FilterChainProxy. + + // Set to true to add a filter chain delegator. + //filterChainProxyDelegator.add = true + + // The name of the delegate FilterChainProxy bean. You must ensure you have added a bean + // with this name that implements FilterChainProxy to + // YOUR-APP/grails-app/conf/spring/resources.groovy. + //filterChainProxyDelegator.targetBeanName = "bvpSecurePluginFilter" + + // The URL pattern to which the filter will apply. Usually set to '/*' to cover all URLs. + //filterChainProxyDelegator.urlPattern = "/*" + + // Set to true to add Listeners + //listener.add = true + //listener.classNames = ["org.springframework.web.context.request.RequestContextListener"] + + //------------------------------------------------- + // These settings usually do not need to be changed + //------------------------------------------------- + + // The name of the delegating filter. + //filterChainProxyDelegator.filterName = "filterChainProxyDelegator" + + // The delegating filter proxy class. + //filterChainProxyDelegator.className = "org.springframework.web.filter.DelegatingFilterProxy" + + // ------------------------------------------------ + // Example for context aparameters + // ------------------------------------------------ + // this example will create the following XML part + // contextparams = [port: '6001'] + // + // + // port + // 6001 + // +} diff --git a/grails-app/conf/au/org/ala/volunteer/ActivityFilters.groovy b/grails-app/conf/au/org/ala/volunteer/ActivityFilters.groovy index 0caebfbee..c9136b6fb 100644 --- a/grails-app/conf/au/org/ala/volunteer/ActivityFilters.groovy +++ b/grails-app/conf/au/org/ala/volunteer/ActivityFilters.groovy @@ -4,7 +4,12 @@ import au.org.ala.cas.util.AuthenticationCookieUtils class ActivityFilters { + def achievementService def userService + def securityPrimitives + def fullTextIndexService + def settingsService + def domainUpdateService def filters = { allButAjax(controller:'*', controllerExclude:'ajax', action:'*') { @@ -28,5 +33,28 @@ class ActivityFilters { } } + + buildInfo(controller: 'buildInfo', action: '*') { + before = { + log.debug("Build Info controller") + securityPrimitives.isAnyGranted([au.org.ala.web.CASRoles.ROLE_ADMIN]) + } + } + + achievements(controller:'*', action:'*') { + after = { Map model -> + log.debug("achievements filter") + //def projectSet = GormEventDebouncer.projectSet + def taskSet = GormEventDebouncer.taskSet + def deletedTasks = GormEventDebouncer.deletedTaskSet + //def fieldSet = GormEventDebouncer.fieldSet + try { + domainUpdateService.onTasksDeleted(deletedTasks) + domainUpdateService.onTasksUpdated(taskSet) + } catch (Exception e) { + log.error("Exception while performing post request actions", e) + } + } + } } } diff --git a/grails-app/conf/default-labels.json b/grails-app/conf/default-labels.json new file mode 100644 index 000000000..c538dc599 --- /dev/null +++ b/grails-app/conf/default-labels.json @@ -0,0 +1,269 @@ +{ + "country": [ + "Afghanistan", + "Akrotiri", + "Albania", + "Algeria", + "American Samoa", + "Andorra", + "Angola", + "Anguilla", + "Antarctica", + "Antigua and Barbuda", + "Argentina", + "Armenia", + "Aruba", + "Ashmore and Cartier Islands", + "Australia", + "Austria", + "Azerbaijan", + "Bahamas, The", + "Bahrain", + "Bangladesh", + "Barbados", + "Bassas da India", + "Belarus", + "Belgium", + "Belize", + "Benin", + "Bermuda", + "Bhutan", + "Bolivia", + "Bosnia and Herzegovina", + "Botswana", + "Bouvet Island", + "Brazil", + "British Indian Ocean Territory", + "British Virgin Islands", + "Brunei", + "Bulgaria", + "Burkina Faso", + "Burma", + "Burundi", + "Cambodia", + "Cameroon", + "Canada", + "Cape Verde", + "Cayman Islands", + "Central African Republic", + "Chad", + "Chile", + "China", + "Christmas Island", + "Clipperton Island", + "Cocos (Keeling) Islands", + "Colombia", + "Comoros", + "Congo, Democratic Republic of the", + "Congo, Republic of the", + "Cook Islands", + "Coral Sea Islands", + "Costa Rica", + "Cote d'Ivoire", + "Croatia", + "Cuba", + "Cyprus", + "Czech Republic", + "Denmark", + "Dhekelia", + "Djibouti", + "Dominica", + "Dominican Republic", + "Ecuador", + "Egypt", + "El Salvador", + "Equatorial Guinea", + "Eritrea", + "Estonia", + "Ethiopia", + "Europa Island", + "Falkland Islands (Islas Malvinas)", + "Faroe Islands", + "Fiji", + "Finland", + "France", + "French Guiana", + "French Polynesia", + "French Southern and Antarctic Lands", + "Gabon", + "Gambia, The", + "Gaza Strip", + "Georgia", + "Germany", + "Ghana", + "Gibraltar", + "Glorioso Islands", + "Greece", + "Greenland", + "Grenada", + "Guadeloupe", + "Guam", + "Guatemala", + "Guernsey", + "Guinea", + "Guinea-Bissau", + "Guyana", + "Haiti", + "Heard Island and McDonald Islands", + "Holy See (Vatican City)", + "Honduras", + "Hong Kong", + "Hungary", + "Iceland", + "India", + "Indonesia", + "Iran", + "Iraq", + "Ireland", + "Isle of Man", + "Israel", + "Italy", + "Jamaica", + "Jan Mayen", + "Japan", + "Jersey", + "Jordan", + "Juan de Nova Island", + "Kazakhstan", + "Kenya", + "Kiribati", + "Korea, North", + "Korea, South", + "Kuwait", + "Kyrgyzstan", + "Laos", + "Latvia", + "Lebanon", + "Lesotho", + "Liberia", + "Libya", + "Liechtenstein", + "Lithuania", + "Luxembourg", + "Macau", + "Macedonia", + "Madagascar", + "Malawi", + "Malaysia", + "Maldives", + "Mali", + "Malta", + "Marshall Islands", + "Martinique", + "Mauritania", + "Mauritius", + "Mayotte", + "Mexico", + "Micronesia, Federated States of", + "Moldova", + "Monaco", + "Mongolia", + "Montserrat", + "Morocco", + "Mozambique", + "Namibia", + "Nauru", + "Navassa Island", + "Nepal", + "Netherlands", + "Netherlands Antilles", + "New Caledonia", + "New Zealand", + "Nicaragua", + "Niger", + "Nigeria", + "Niue", + "Norfolk Island", + "Northern Mariana Islands", + "Norway", + "Oman", + "Pakistan", + "Palau", + "Panama", + "Papua New Guinea", + "Paracel Islands", + "Paraguay", + "Peru", + "Philippines", + "Pitcairn Islands", + "Poland", + "Portugal", + "Puerto Rico", + "Qatar", + "Reunion", + "Romania", + "Russia", + "Rwanda", + "Saint Helena", + "Saint Kitts and Nevis", + "Saint Lucia", + "Saint Pierre and Miquelon", + "Saint Vincent and the Grenadines", + "Samoa", + "San Marino", + "Sao Tome and Principe", + "Saudi Arabia", + "Senegal", + "Serbia and Montenegro", + "Seychelles", + "Sierra Leone", + "Singapore", + "Slovakia", + "Slovenia", + "Solomon Islands", + "Somalia", + "South Africa", + "South Georgia and the South Sandwich Islands", + "Spain", + "Spratly Islands", + "Sri Lanka", + "Sudan", + "Suriname", + "Svalbard", + "Swaziland", + "Sweden", + "Switzerland", + "Syria", + "Taiwan", + "Tajikistan", + "Tanzania", + "Thailand", + "Timor-Leste", + "Togo", + "Tokelau", + "Tonga", + "Trinidad and Tobago", + "Tromelin Island", + "Tunisia", + "Turkey", + "Turkmenistan", + "Turks and Caicos Islands", + "Tuvalu", + "Uganda", + "Ukraine", + "United Arab Emirates", + "United Kingdom", + "United States", + "Uruguay", + "Uzbekistan", + "Vanuatu", + "Venezuela", + "Vietnam", + "Virgin Islands", + "Wake Island", + "Wallis and Futuna", + "West Bank", + "Western Sahara", + "Yemen", + "Zambia", + "Zimbabwe" + ], + "field": [ + "Malacology", + "Marine Invertebrates", + "Botany", + "Entomology" + ] +} + + \ No newline at end of file diff --git a/grails-app/conf/spring/resources.groovy b/grails-app/conf/spring/resources.groovy index 5c09d92d6..6c5577037 100644 --- a/grails-app/conf/spring/resources.groovy +++ b/grails-app/conf/spring/resources.groovy @@ -1,3 +1,5 @@ +import au.org.ala.volunteer.ApplicationContextHolder +import au.org.ala.volunteer.BVPSecurePluginFilter import au.org.ala.volunteer.collectory.CollectoryClientFactoryBean // Place your Spring DSL code here @@ -9,4 +11,12 @@ beans = { collectoryClient(CollectoryClientFactoryBean) { endpoint = 'http://collections.ala.org.au/ws/' } + +// bvpSecurePluginFilter(BVPSecurePluginFilter) { +// securityPrimitives = ref("securityPrimitives") +// } + + applicationContextHolder(ApplicationContextHolder) { bean -> + bean.factoryMethod = 'getInstance' + } } diff --git a/grails-app/controllers/au/org/ala/volunteer/AchievementDescriptionController.groovy b/grails-app/controllers/au/org/ala/volunteer/AchievementDescriptionController.groovy new file mode 100644 index 000000000..3e423a8f7 --- /dev/null +++ b/grails-app/controllers/au/org/ala/volunteer/AchievementDescriptionController.groovy @@ -0,0 +1,350 @@ +package au.org.ala.volunteer + +import au.org.ala.web.AlaSecured +import grails.converters.JSON +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.multipart.MultipartHttpServletRequest + +import static grails.async.Promises.* +import static org.springframework.http.HttpStatus.* +import grails.transaction.Transactional + +@AlaSecured("ROLE_VP_ADMIN") +@Transactional(readOnly = true) +class AchievementDescriptionController { + + static allowedMethods = [save: "POST", update: "PUT", delete: "DELETE", uploadBadgeImage: "POST", award: "POST", awardAll: "POST", enable: "POST"] + + def achievementService + def userService + + def index(Integer max) { + params.max = Math.min(max ?: 10, 100) + respond AchievementDescription.list(params), model: [achievementDescriptionInstanceCount: AchievementDescription.count()] + } + + def show(AchievementDescription achievementDescriptionInstance) { + redirect action: 'edit', id: achievementDescriptionInstance.id + } + + def create() { + respond new AchievementDescription() + } + + @Transactional + def save(AchievementDescription achievementDescriptionInstance) { + if (achievementDescriptionInstance == null) { + notFound() + return + } + + if (achievementDescriptionInstance.hasErrors()) { + respond achievementDescriptionInstance.errors, view: 'create' + return + } + + achievementDescriptionInstance.save flush: true + + cleanBadges() + + request.withFormat { + form multipartForm { + flash.message = message(code: 'default.created.message', args: [message(code: 'achievementDescription.label', default: 'AchievementDescription'), achievementDescriptionInstance.id]) + redirect achievementDescriptionInstance + } + '*' { respond achievementDescriptionInstance, [status: CREATED] } + } + } + + def edit(AchievementDescription achievementDescriptionInstance) { + respond achievementDescriptionInstance + } + + def editTest(AchievementDescription achievementDescriptionInstance) { + + def userId = params.userId ?: userService.currentUserId + def user = User.findByUserId(userId) + def eval = achievementService.evaluateAchievement(achievementDescriptionInstance, user, null) + def cheevMap = ["$user.displayName": eval] + + request.withFormat { + form html { + render view: 'editTest', model: [achievementDescriptionInstance: achievementDescriptionInstance, cheevMap: cheevMap, displayName: user?.displayName, userId: userId] + } + '*' { respond((Object)cheevMap, status: OK) } + } + } + + @Transactional + def update(AchievementDescription achievementDescriptionInstance) { + if (achievementDescriptionInstance == null) { + notFound() + return + } + + if (achievementDescriptionInstance.hasErrors()) { + respond achievementDescriptionInstance.errors, view: 'edit' + return + } + + achievementDescriptionInstance.save flush: true + + cleanBadges() + + request.withFormat { + form multipartForm { + flash.message = message(code: 'default.updated.message', args: [message(code: 'achievementDescription.label', default: 'AchievementDescription'), achievementDescriptionInstance.id]) + redirect action: 'edit', id: achievementDescriptionInstance.id + } + '*' { respond achievementDescriptionInstance, [status: OK] } + } + } + + @Transactional + def delete(AchievementDescription achievementDescriptionInstance) { + + if (achievementDescriptionInstance == null) { + notFound() + return + } + + achievementDescriptionInstance.delete flush: true + + request.withFormat { + form multipartForm { + flash.message = message(code: 'default.deleted.message', args: [message(code: 'achievementDescription.label', default: 'AchievementDescription'), achievementDescriptionInstance.id]) + redirect action: "index", method: "GET" + } + '*' { render status: NO_CONTENT } + } + } + + protected void notFound() { + request.withFormat { + form multipartForm { + flash.message = message(code: 'default.not.found.message', args: [message(code: 'achievementDescription.label', default: 'AchievementDescription'), params.id]) + redirect action: "index", method: "GET" + } + '*' { render status: NOT_FOUND } + } + } + + def run(Long achievementId, Long userId, Long taskId) { + achievementService.evaluateAchievement(AchievementDescription.get(achievementId), User.get(userId), taskId) + } + + @Transactional + def uploadBadgeImage() { + def id = params.long("id"); + def achievement = id ? AchievementDescription.get(id) : null + + + def json = [:] + def status = OK + if (request instanceof MultipartHttpServletRequest) { + MultipartFile f = ((MultipartHttpServletRequest) request).getFile('imagefile') + + if (f != null && f.size > 0) { + def allowedMimeTypes = ['image/jpeg', 'image/png'] + if (!allowedMimeTypes.contains(f.getContentType())) { + json.put("error", "Image must be one of: ${allowedMimeTypes}") + status = BAD_REQUEST + } else { + boolean result + String filename = UUID.randomUUID().toString() + '.' + contentTypeToExtension(f.contentType) + result = uploadToLocalPath(f, filename) + + if (result) { + json.put('filename', filename) + if (achievement) { + achievement.badge = filename + achievement.save(flush: true) + } + } else { + json.put('error', "Failed to upload image. Unknown error!") + status = INTERNAL_SERVER_ERROR + } + } + } else { + json.put('error', "Please select a file!") + status = BAD_REQUEST + } + } else { + json.put('error', "Form must be multipart file!") + status = BAD_REQUEST + } + + respond((Object)json, status: status.value()) + } + + def awards(AchievementDescription achievementDescriptionInstance) { + //def evals = achievementDescriptionInstance.awards*.user.collectEntries { [ (it.userId) : achievementService.evaluateAchievement(achievementDescriptionInstance, it, null)] } + respond achievementDescriptionInstance//, [model: [evals: evals]] + } + + def checkAward(AchievementDescription achievementDescriptionInstance) { + def ids = (params.list('ids[]') ?: [])*.toLong() + def users = User.findAllByIdInList(ids) + def result = users.collectEntries { [ (it.userId) : achievementService.evaluateAchievement(achievementDescriptionInstance, it, null) ] } + render result as JSON + } + + @Transactional + def awardAll(AchievementDescription achievementDescriptionInstance) { + + def awardedUsers = achievementDescriptionInstance.awards*.user*.id.toList() + def eligibleUsers = awardedUsers ? User.findAllByIdNotInList(awardedUsers) : User.all + + def awards = eligibleUsers + .findAll { achievementService.evaluateAchievement(achievementDescriptionInstance, it, null) } + .collect { new AchievementAward(user: it, achievement: achievementDescriptionInstance, awarded: new Date()) } + + AchievementAward.saveAll(awards) + + request.withFormat { + form multipartForm { + flash.message = awards.collect { message(code: 'achievement.awarded.message', args: [achievementDescriptionInstance.name, it.user.displayName]) }.join('
') + redirect action: 'awards', id: achievementDescriptionInstance.id + } + '*' { respond awards, [status: OK] } + } + } + + @Transactional + def award(AchievementDescription achievementDescriptionInstance) { + + def userId = params.userId + def user = User.findByUserId(userId) + + if (!user) { + flash.message = message(code: 'default.not.found.message', args: [message(code: 'user.label', default: 'User'), userId]) + redirect action: 'awards', id: achievementDescriptionInstance.id + return + } + + def award = new AchievementAward(user: user, achievement: achievementDescriptionInstance, awarded: new Date()) + award.save flush: true + + request.withFormat { + form multipartForm { + flash.message = message(code: 'achievement.awarded.message', args: [achievementDescriptionInstance.name, user.displayName]) + redirect action: 'awards', id: achievementDescriptionInstance.id + } + '*' { respond award, [status: OK] } + } + } + + @Transactional + def unawardAll(AchievementDescription achievementDescriptionInstance) { + def awards = AchievementAward.findAllByAchievement(achievementDescriptionInstance) + log.info("Removing awarded achievements: ${awards.join('\n')}") + + AchievementAward.deleteAll(awards) + + request.withFormat { + form multipartForm { + flash.message = message(code: 'achievement.removed.message', args: [achievementDescriptionInstance.name, awards*.user*.displayName]) + redirect action: 'awards', id: achievementDescriptionInstance.id + } + '*' { render status: NO_CONTENT.value() } + } + } + + @Transactional + def unaward(AchievementDescription achievementDescriptionInstance) { + def awardIds = params.list('ids[]')*.toLong() + def awards = AchievementAward.findAllByIdInListAndAchievement(awardIds, achievementDescriptionInstance) + log.info("Removing awarded achievements: ${awards.join('\n')}") + + AchievementAward.deleteAll(awards) + + request.withFormat { + form multipartForm { + flash.message = message(code: 'achievement.removed.message', args: [achievementDescriptionInstance.name, awards*.user*.displayName]) + redirect action: 'awards', id: achievementDescriptionInstance.id + } + '*' { render status: NO_CONTENT.value() } + } + } + + def findEligibleUsers(AchievementDescription achievementDescriptionInstance) { + // todo search Atlas User Details + def term = params.term + def filter = params.boolean('filter', true) + def ineligible = filter ? achievementDescriptionInstance.awards*.user*.userId : [] + def search = "%${term}%" + def users = User.withCriteria { + or { + ilike 'displayName', search + ilike 'email', search + } + if (ineligible) { + not { + inList 'userId', ineligible + } + } + maxResults 20 + order "displayName", "desc" + } + + render users as JSON + } + + @Transactional + def enable(AchievementDescription achievementDescriptionInstance) { + def enabledParam = params.boolean('enabled') ?: false + achievementDescriptionInstance.enabled = enabledParam + achievementDescriptionInstance.save(flush: true) + render status: NO_CONTENT.value() + } + + private static String contentTypeToExtension(String contentType) { + switch (contentType.toLowerCase()) { + case 'image/png': + return 'png' + case 'image/jpeg': + return 'jpg' + case 'image/gif': + return 'gif' + case 'image/webp': + return 'webp' + case 'image/tiff': + case 'image/tiff-fx': + return 'tiff' + case 'image/bmp': + case 'image/x-bmp': + return 'bmp' + default: + return '' + } + } + + private boolean uploadToLocalPath(MultipartFile mpfile, String localFile) { + if (!mpfile) { + return false + } + + try { + def file = new File(achievementService.badgeImageFilePrefix, localFile) + if (!file.getParentFile().exists() && !file.getParentFile().mkdirs()) { + throw new RuntimeException("Failed to create institution directories: ${file.getParentFile().getAbsolutePath()}") + } + mpfile.transferTo(file); + return true + } catch (Exception ex) { + log.error("Failed to upload achievement badge", ex) + return false + } + } + + private void cleanBadges() { + def badges = AchievementDescription.withCriteria { + projections { + property("badge") + } + } + task { + achievementService.cleanImageDir(badges) + } + } +} diff --git a/grails-app/controllers/au/org/ala/volunteer/AdminController.groovy b/grails-app/controllers/au/org/ala/volunteer/AdminController.groovy index 40a615f9f..84226528c 100644 --- a/grails-app/controllers/au/org/ala/volunteer/AdminController.groovy +++ b/grails-app/controllers/au/org/ala/volunteer/AdminController.groovy @@ -1,6 +1,7 @@ package au.org.ala.volunteer import groovy.time.TimeCategory +import org.elasticsearch.action.search.SearchType import org.grails.plugins.csv.CSVWriter import org.hibernate.FlushMode import org.springframework.web.multipart.MultipartHttpServletRequest @@ -12,11 +13,14 @@ class AdminController { def taskService def grailsApplication + def grailsCacheAdminService def tutorialService def sessionFactory def userService def projectService def fullTextIndexService + def domainUpdateService + def taskLoadService def index = { checkAdmin() @@ -330,7 +334,7 @@ class AdminController { } results?.each { long taskId -> - fullTextIndexService.scheduleTaskIndex(taskId) + DomainUpdateService.scheduleTaskIndex(taskId) } } @@ -344,5 +348,72 @@ class AdminController { redirect(action:'tools') } + + def testQuery(String query, String searchType, String aggregation) { + def searchTypeVal = searchType ? SearchType.fromString(searchType) : SearchType.DEFAULT + log.debug("SearchType: $searchType, $searchTypeVal") + +// def offset = params.offset +// def + + def result = fullTextIndexService.rawSearch(query, searchTypeVal, aggregation, fullTextIndexService.elasticSearchToJsonString) + + response.setContentType("application/json") + render result + } + + // clear the grails gsp caches + def clearPageCaches() { + if (!checkAdmin()) { + render status: 403 + } + grailsCacheAdminService.clearTemplatesCache() + grailsCacheAdminService.clearBlocksCache() + flash.message = "Template and blocks caches cleared" + redirect action: 'tools' + } + + def clearAllCaches() { + if (!checkAdmin()) { + render status: 403 + } + grailsCacheAdminService.clearAllCaches() + flash.message = "All caches cleared" + redirect action: 'tools' + } + + def stagingTasks() { + if (!checkAdmin()) { + render status: 403 + } + + def status = taskLoadService.status() + def queueItems = taskLoadService.currentQueue() + + respond queueItems, model: [status: status] + } + + def cancelStagingQueue() { + if (!checkAdmin()) { + render status: 403 + } + + taskLoadService.cancelLoad() + flash.message = "Task Load Cancel message sent" + + redirect action: 'stagingTasks' + } + + def clearStagingQueue() { + if (!checkAdmin()) { + render status: 403 + } + + def items = taskLoadService.clearQueue() + flash.message = "Task Load queue cleared, remaining items: ${items.join(', ')}" + + + redirect action: 'stagingTasks' + } } diff --git a/grails-app/controllers/au/org/ala/volunteer/AjaxController.groovy b/grails-app/controllers/au/org/ala/volunteer/AjaxController.groovy index 71c1f581d..f9e968454 100644 --- a/grails-app/controllers/au/org/ala/volunteer/AjaxController.groovy +++ b/grails-app/controllers/au/org/ala/volunteer/AjaxController.groovy @@ -23,9 +23,10 @@ class AjaxController { DataSource dataSource def multimediaService def institutionService - def fullTextIndexService + def domainUpdateService def authService def settingsService + def achievementService static responseFormats = ['json', 'xml'] @@ -46,10 +47,8 @@ class AjaxController { def projects = Project.findAllByProjectType(it) stats[it.description ?: it.name] = Task.countByProjectInList(projects) } - - def ineligibleUsers = settingsService.getSetting(SettingDefinition.IneligibleLeaderBoardUsers) - def volunteerCounts = userService.getUserCounts(ineligibleUsers) + def volunteerCounts = userService.userCounts stats.volunteerCount = volunteerCounts?.size() if (volunteerCounts?.size() >= 10) { stats.topTenVolunteers = volunteerCounts[0..9] @@ -346,10 +345,23 @@ class AjaxController { respond results } - def getIndexerQueueLength() { - def length = fullTextIndexService.getIndexerQueueLength() + def getUpdateQueueLength() { + def length = domainUpdateService.getQueueLength() def results = ['success': true, 'queueLength': length] respond results } + def acceptAchievements() { + def ids = params.list('ids[]') ?: [] + def longIds = ids*.toLong() + if (!longIds) { + render status: 204 + return + } + def cu = userService.currentUser + def validAwards = AchievementAward.findAllByIdInListAndUser(longIds, cu) + if (validAwards) achievementService.markAchievementsViewed(validAwards*.id) + render status: 204 + } + } diff --git a/grails-app/controllers/au/org/ala/volunteer/LabelController.groovy b/grails-app/controllers/au/org/ala/volunteer/LabelController.groovy new file mode 100644 index 000000000..8697e6690 --- /dev/null +++ b/grails-app/controllers/au/org/ala/volunteer/LabelController.groovy @@ -0,0 +1,129 @@ +package au.org.ala.volunteer + + +import static org.springframework.http.HttpStatus.* +import grails.transaction.Transactional + +@Transactional(readOnly = true) +class LabelController { + + static allowedMethods = [save: "POST", update: "PUT", delete: "DELETE"] + + def userService + + boolean checkAdmin() { + if (userService.isAdmin()) { + return true; + } + + flash.message = "You do not have permission to view this page" + redirect(uri:"/") + } + + def index(Integer max) { + if (!checkAdmin()) { + render status: FORBIDDEN + return + } + params.max = Math.min(max ?: 25, 100) + respond Label.list(params), model: [labelInstanceCount: Label.count()] + } + +// def show(Label labelInstance) { +// respond labelInstance +// } +// +// def create() { +// respond new Label(params) +// } + + @Transactional + def save(Label labelInstance) { + if (!checkAdmin()) { + redirect(controller: 'frontPage') + return + } + + if (labelInstance == null) { + notFound() + return + } + + if (labelInstance.hasErrors()) { + respond labelInstance.errors, view: 'create' + return + } + + labelInstance.save flush: true + + request.withFormat { + form multipartForm { + flash.message = message(code: 'default.created.message', args: [message(code: 'label.label', default: 'Label'), labelInstance.value]) + redirect action: 'index' + } + '*' { respond labelInstance, [status: CREATED] } + } + } + +// def edit(Label labelInstance) { +// respond labelInstance +// } + + @Transactional + def update(Label labelInstance) { + if (!checkAdmin()) { + redirect(controller: 'frontPage') + return + } + + if (labelInstance == null) { + notFound() + return + } + + if (labelInstance.hasErrors()) { + respond labelInstance.errors, view: 'edit' + return + } + + labelInstance.save flush: true + + request.withFormat { + form multipartForm { + flash.message = message(code: 'default.updated.message', args: [message(code: 'Label.label', default: 'Label'), labelInstance.value]) + redirect action: 'index' + } + '*' { respond labelInstance, [status: OK] } + } + } + + @Transactional + def delete(Label labelInstance) { + + if (labelInstance == null) { + notFound() + return + } + + labelInstance.delete flush: true + + request.withFormat { + form multipartForm { + flash.message = message(code: 'default.deleted.message', args: [message(code: 'Label.label', default: 'Label'), labelInstance.id]) + redirect action: "index", method: "GET" + } + '*' { render status: NO_CONTENT } + } + } + + protected void notFound() { + request.withFormat { + form multipartForm { + flash.message = message(code: 'default.not.found.message', args: [message(code: 'label.label', default: 'Label'), params.id]) + redirect action: "index", method: "GET" + } + '*' { render status: NOT_FOUND } + } + } + +} diff --git a/grails-app/controllers/au/org/ala/volunteer/LeaderBoardController.groovy b/grails-app/controllers/au/org/ala/volunteer/LeaderBoardController.groovy index f988371a8..bd8302a24 100644 --- a/grails-app/controllers/au/org/ala/volunteer/LeaderBoardController.groovy +++ b/grails-app/controllers/au/org/ala/volunteer/LeaderBoardController.groovy @@ -65,7 +65,7 @@ class LeaderBoardController { } else { def userScores = userService.getUserCounts(ineligibleUsers); if (userScores) { - result = [name: userScores[0][0], score: userScores[0][1]] + result = [name: userScores[0]['displayName'], score: userScores[0]['total']] } } @@ -122,7 +122,7 @@ class LeaderBoardController { if (i >= maxRows) { break; } - results << [name: userScores[i][0], score: userScores[i][1], userId: userScores[i][2]] + results << [name: userScores[i]['displayName'], score: userScores[i]['total'], userId: userScores[i]['id']] } } break; diff --git a/grails-app/controllers/au/org/ala/volunteer/ProjectController.groovy b/grails-app/controllers/au/org/ala/volunteer/ProjectController.groovy index 2d9692cb6..8fb1924ad 100644 --- a/grails-app/controllers/au/org/ala/volunteer/ProjectController.groovy +++ b/grails-app/controllers/au/org/ala/volunteer/ProjectController.groovy @@ -369,7 +369,12 @@ class ProjectController { final insts = Institution.list() final names = insts*.name final nameToId = insts.collectEntries { ["${it.name}": it.id] } - return [projectInstance: projectInstance, templates: Template.listOrderByName(), projectTypes: ProjectType.listOrderByName(), institutions: names, institutionsMap: nameToId] + final labelCats = Label.withCriteria { projections { distinct 'category' } } + final colours = ["", "label-success", "label-warning", "label-important", "label-info", "label-inverse"] + final sortedLabels = projectInstance.labels.sort { a,b -> def x = a.category?.compareTo(b.category); return x == 0 ? a.value.compareTo(b.value) : x } + def counter = 0 + final catColourMap = labelCats.collectEntries { [(it): colours[counter++ % colours.size()]] } + return [projectInstance: projectInstance, templates: Template.listOrderByName(), projectTypes: ProjectType.listOrderByName(), institutions: names, institutionsMap: nameToId, labelColourMap: catColourMap, sortedLabels: sortedLabels] } } @@ -809,11 +814,50 @@ class ProjectController { flow.project.mapInitLongitude = Double.parseDouble(params.mapLongitude) } - }.to "summary" + }.to "projectExtras" on("cancel").to "cancel" on("back").to "projectImage" } + projectExtras { + onEntry { + def c = PicklistItem.createCriteria(); + def picklistInstitutionCodes = c { + isNotNull("institutionCode") + projections { + distinct("institutionCode") + } + order('institutionCode') + } + + flow.picklists = picklistInstitutionCodes + final labelCats = Label.withCriteria { projections { distinct 'category' } } + final colours = ["", "label-success", "label-warning", "label-important", "label-info", "label-inverse"] + def counter = 0 + final catColourMap = labelCats.collectEntries { [(it): colours[counter++ % colours.size()]] } + flow.labelColourMap = catColourMap + } + on("continue") { + flow.project.picklistId = params.picklistId + def labelIdArr = params.getList('labelId[]') + flow.project.labelIds = labelIdArr.collect { Long.valueOf(it) } + + + def flowLabels + if (flow.project.labelIds) { + def labelIds = flow.project.labelIds + def labels = Label.withCriteria { inList 'id', labelIds } + def labelMap = labels*.toMap() + flowLabels = labelMap + } else { flowLabels = [] } + + flow.labels = flowLabels + + }.to "summary" + on("cancel").to "cancel" + on("back").to "projectMap" + } + summary { onEntry { if (projectStagingService.hasProjectImage(flow.project)) { @@ -822,10 +866,13 @@ class ProjectController { flow.projectImageUrl = null } flow.projectTypeImageUrl = projectTypeService.getIconURL(ProjectType.get(flow.project.projectTypeId)) + + flow.templateName = Template.get(flow.project.templateId)?.name + flow.projectTypeLabel = ProjectType.get(flow.project.projectTypeId)?.label } on("continue").to "createProject" on("cancel").to "cancel" - on("back").to "projectMap" + on("back").to "projectExtras" } createProject { @@ -906,4 +953,55 @@ class ProjectController { } + def addLabel(Project projectInstance) { + def labelId = params.labelId + def label = Label.get(labelId) + if (!label) { + render status: 404 + return + } + + projectInstance.addToLabels(label) + // Just adding a label won't trigger the GORM update event, so force a project update + DomainUpdateService.scheduleProjectUpdate(projectInstance.id) + render status: 204 + } + + def removeLabel(Project projectInstance) { + def labelId = params.labelId + def label = Label.get(labelId) + if (!label) { + render status: 404 + return + } + + projectInstance.removeFromLabels(label) + // Just adding a label won't trigger the GORM update event, so force a project update + DomainUpdateService.scheduleProjectUpdate(projectInstance.id) + render status: 204 + } + + def newLabels(Project projectInstance) { + def term = params.term ?: '' + def ilikeTerm = "%${term.replace('%','')}%" + def existing = projectInstance?.labels + def labels + + if (existing) { + def existingIds = existing*.id.toList() + labels = Label.withCriteria { + or { + ilike 'category', ilikeTerm + ilike 'value', ilikeTerm + } + not { + inList 'id', existingIds + } + } + } else { + labels = Label.findAllByCategoryIlikeOrValueIlike(ilikeTerm, ilikeTerm) + } + + render labels as JSON + } } diff --git a/grails-app/controllers/au/org/ala/volunteer/ProjectToolsController.groovy b/grails-app/controllers/au/org/ala/volunteer/ProjectToolsController.groovy index d5834b8b5..c7ab049f1 100644 --- a/grails-app/controllers/au/org/ala/volunteer/ProjectToolsController.groovy +++ b/grails-app/controllers/au/org/ala/volunteer/ProjectToolsController.groovy @@ -3,7 +3,6 @@ package au.org.ala.volunteer class ProjectToolsController { def projectToolsService - def fullTextIndexService def matchRecordedByIdFromPicklist() { def projectInstance = Project.get(params.id) @@ -25,7 +24,7 @@ class ProjectToolsController { } } taskList.each { long taskId -> - fullTextIndexService.scheduleTaskIndex(taskId) + DomainUpdateService.scheduleTaskIndex(taskId) } flash.message = "${taskList.size()} tasks scheduled for indexing." } diff --git a/grails-app/controllers/au/org/ala/volunteer/PublicController.groovy b/grails-app/controllers/au/org/ala/volunteer/PublicController.groovy index 7556a60c2..865620608 100644 --- a/grails-app/controllers/au/org/ala/volunteer/PublicController.groovy +++ b/grails-app/controllers/au/org/ala/volunteer/PublicController.groovy @@ -13,7 +13,7 @@ class PublicController {     * @param appUrl the url to redirect back to after the logout     */ def logout = { - logService.log "Invalidating Session (PublicController.logout): ${session.id}" + log.info "Invalidating Session (PublicController.logout): ${session.id}" session.invalidate() redirect(url: "${params.casUrl}?url=${params.appUrl}") } diff --git a/grails-app/controllers/au/org/ala/volunteer/TaskController.groovy b/grails-app/controllers/au/org/ala/volunteer/TaskController.groovy index 4ce446920..24c5f3388 100644 --- a/grails-app/controllers/au/org/ala/volunteer/TaskController.groovy +++ b/grails-app/controllers/au/org/ala/volunteer/TaskController.groovy @@ -157,7 +157,9 @@ class TaskController { * Webservice for Google Maps to display task details in infowindow */ def details() { - def taskInstance = Task.get(params.id) + def id = params.int('id') + def sid = params.id + def taskInstance = Task.get(params.int('id')) Map recordValues = fieldSyncService.retrieveFieldsForTask(taskInstance) def jsonObj = [:] jsonObj.put("cat", recordValues?.get(0)?.catalogNumber) @@ -316,7 +318,7 @@ class TaskController { def template = Template.findById(project.template.id) def isReadonly = 'readonly' def isValidator = userService.isValidator(project) - logService.log currentUser + " has role: ADMIN = " + userService.isAdmin() + " && VALIDATOR = " + isValidator + log.info currentUser + " has role: ADMIN = " + userService.isAdmin() + " && VALIDATOR = " + isValidator def imageMetaData = taskService.getImageMetaData(taskInstance) diff --git a/grails-app/controllers/au/org/ala/volunteer/TranscribeController.groovy b/grails-app/controllers/au/org/ala/volunteer/TranscribeController.groovy index f382eef4b..7f702c43f 100644 --- a/grails-app/controllers/au/org/ala/volunteer/TranscribeController.groovy +++ b/grails-app/controllers/au/org/ala/volunteer/TranscribeController.groovy @@ -59,7 +59,7 @@ class TranscribeController { def isReadonly = false def isValidator = userService.isValidator(project) - logService.log(currentUserId + " has role: ADMIN = " + userService.isAdmin() + " && VALIDATOR = " + isValidator) + log.info(currentUserId + " has role: ADMIN = " + userService.isAdmin() + " && VALIDATOR = " + isValidator) if (taskInstance.fullyTranscribedBy && taskInstance.fullyTranscribedBy != currentUserId && !userService.isAdmin()) { isReadonly = "readonly" } diff --git a/grails-app/controllers/au/org/ala/volunteer/UserController.groovy b/grails-app/controllers/au/org/ala/volunteer/UserController.groovy index a3aae0a52..4868969d0 100644 --- a/grails-app/controllers/au/org/ala/volunteer/UserController.groovy +++ b/grails-app/controllers/au/org/ala/volunteer/UserController.groovy @@ -1,9 +1,17 @@ package au.org.ala.volunteer +import com.google.common.base.Stopwatch import grails.converters.JSON +import groovy.text.SimpleTemplateEngine import org.codehaus.groovy.grails.web.servlet.mvc.GrailsParameterMap +import org.elasticsearch.action.search.SearchResponse +import org.elasticsearch.action.search.SearchType +import org.elasticsearch.search.aggregations.bucket.terms.StringTerms + import java.text.SimpleDateFormat +import static au.org.ala.volunteer.FullTextIndexService.* + class UserController { static allowedMethods = [save: "POST", update: "POST", delete: "POST"] @@ -11,18 +19,73 @@ class UserController { def grailsApplication def taskService def userService - def achievementService def logService def fieldService def forumService def authService + def fullTextIndexService + + static final ALA_HARVESTABLE = '''{ + "constant_score": { + "filter": { + "and": [ + { "term": { "project.harvestableByAla": true } }, + { "term": { "fullyTranscribedBy": "${userId}" } } + ] + } + } +}''' + + static final SPECIES_AGG_TEMPLATE = ''' +{ + "fields": { + "nested": { + "path": "fields" + }, + "aggs": { + "speciesfields" : { + "filter" : { "term" : { "fields.name" : "scientificName" } }, + "aggs" : { + "species" : { + "terms" : { "field" : "fields.value", "size": 0 } + } + } + } + } + } +} +''' + + static final MATCH_ALL = '{ "constant_score" : { "query": { "match_all": { } } } }' + + static final FIELD_OBSERVATIONS = '''{ + "constant_score": { + "filter": { + "and": [ + { "term": { "project.projectType": "fieldnotes" } }, + { "term": { "fullyTranscribedBy": "${userId}" } } + ] + } + } +}''' + + static final VALIDATED_TASKS_FOR_USER = '''{ + "constant_score": { + "filter": { + "and": [ + { "term": { "isValid": true } }, + { "term": { "fullyTranscribedBy": "${userId}" } } + ] + } + } +}''' def index = { redirect(action: "list", params: params) } def logout = { - logService.log "Invalidating Session (UserController.logout): ${session.id}" + log.info "Invalidating Session (UserController.logout): ${session.id}" session.invalidate() redirect(url:"${params.casUrl}?url=${params.appUrl}") } @@ -285,10 +348,7 @@ class UserController { totalTranscribedTasks = userInstance.transcribedCount } - def achievements = [] - if (FrontPage.instance().showAchievements) { - achievements = achievementService.calculateAchievements(userInstance) - } + def achievements = userInstance.achievementAwards def score = userService.getUserScore(userInstance) @@ -447,7 +507,7 @@ class UserController { } - def dashboard() { + def notebook() { def userInstance = userService.currentUser if (params.int("id")) { @@ -464,73 +524,170 @@ class UserController { } def ajaxGetPoints() { - + Stopwatch sw = new Stopwatch(); + sw.start() def userInstance = User.get(params.int("id")) - def tasks = Task.findAllByFullyTranscribedBy(userInstance.userId) - - def data = [] - tasks.each { task -> - def point = fieldService.getPointForTask(task) - if (point) { - data << [lat:point.lat, lng:point.lng, taskId: task.id] + sw.stop() + log.info("ajaxGetPoints| User.get(): ${sw.toString()}") + sw.reset().start() + + Long taskCount = Task.countByFullyTranscribedBy(userInstance.userId) + sw.stop() + log.info("ajaxGetPoints| Task.countByFullyTranscribedBy(): ${sw.toString()}") + sw.reset().start() + + final q = '''{ + "constant_score": { + "filter": { + "and": [ + { "term": { "fullyTranscribedBy": "${userId}" } }, + { "nested" : + { + "path" : "fields", + "filter" : { "term" : { "name": "decimalLongitude"}} + } + }, + { "nested" : + { + "path" : "fields", + "filter" : { "term" : { "name": "decimalLongitude"}} + } + } + ] + } + } +}''' + final simpleTemplateEngine = new SimpleTemplateEngine(); + final query = simpleTemplateEngine.createTemplate(q).make([userId: userInstance.userId]).toString() + + def searchResponse = fullTextIndexService.rawSearch(query, SearchType.QUERY_THEN_FETCH, taskCount.intValue(), rawResponse) + sw.stop() + log.info("ajaxGetPoints| fullTextIndexService.rawSearch(): ${sw.toString()}") + sw.reset().start() + + def data = searchResponse.hits.hits.collect { hit -> + def field = hit.source['fields'] + + //log.error("Keys ${field}") + def pt = field.findAll { value -> + value['name'] == 'decimalLongitude' || value['name'] == 'decimalLatitude' + }.collectEntries { value -> + def dVal = value['value'] +// try { +// dVal = Double.valueOf(value['value']) +// } catch (NumberFormatException e) { +// log.warn("Got invalid lat/lon value ${value['value']}") +// dVal = 0.0 +// } + if (value['name'] == 'decimalLongitude') { + [lng: dVal] + } else { + [lat: dVal] + } } + pt.put('taskId', hit.source['id']) + pt } + sw.stop() + log.info("ajaxGetPoints| generateResults: ${sw.toString()}") + //sw.reset().start() + + //def tasks = Task.findAllByFullyTranscribedBy(userInstance.userId) + + //def data = [] + //tasks.each { task -> +// def point = fieldService.getPointForTask(task) + // if (point) { + // data << [lat:point.lat, lng:point.lng, taskId: task.id] + // } + //} + + + render(data as JSON) } - def dashboardMainFragment() { + def notebookMainFragment() { + Stopwatch sw = new Stopwatch(); def userInstance = User.get(params.int("id")) + def simpleTemplateEngine = new SimpleTemplateEngine() def c = Task.createCriteria() + sw.start() def expeditions = c { eq("fullyTranscribedBy", userInstance.userId) projections { countDistinct("project") } } + sw.stop() - def score = userService.getUserScore(userInstance) - - def achievements = achievementService.calculateAchievements(userInstance) - def userAchievements = Achievement.findAllByUser(userInstance, [sort:'dateAchieved', order:'desc']) + log.info("notebookMainFragment.projectCount ${sw.toString()}") - def recentAchievement - if (userAchievements) { - def top = userAchievements[0] - - recentAchievement = achievements.find { it.name == top.name } - if (recentAchievement) { - recentAchievement.date = top.dateAchieved - } - } - - def speciesCriteria = Field.createCriteria() - def species = speciesCriteria.list(max: 5) { - and { - eq("transcribedByUserId", userInstance.userId) - eq("superceded", false) - ilike("name", "scientificName") - isNotNull("value") - ne("value", "") - } - projections { - groupProperty("value") - count("value","count") - order("count", "desc") - } - - } - - [userInstance: userInstance, expeditionCount: expeditions ? expeditions[0] : 0, score: score, recentAchievement: recentAchievement, topSpecies: species ] + sw.reset().start() + def score = userService.getUserScore(userInstance) + sw.stop() + log.info("notebookMainFragment.getUserScore ${sw.toString()}") + + sw.reset().start() + def recentAchievements = AchievementAward.findAllByUser(userInstance, [sort:'awarded', order:'desc', max: 3]) + sw.stop() + log.info("notebookMainFragment.recentAchievements ${sw.toString()}") + + sw.reset().start() + final query = simpleTemplateEngine.createTemplate(ALA_HARVESTABLE).make([userId: userInstance.userId]).toString() + final agg = SPECIES_AGG_TEMPLATE + + def speciesList2 = fullTextIndexService.rawSearch(query, SearchType.COUNT, agg) { SearchResponse searchResponse -> + searchResponse.aggregations.get('fields').aggregations.get('speciesfields').aggregations.get('species').buckets.collect { [ it.key, it.docCount ] } + }.sort { m -> m[1] } + def totalSpeciesCount = speciesList2.size() + sw.stop() + log.info("notebookMainFragment.speciesList2 ${sw.toString()}") + log.info("specieslist2: ${speciesList2}") + + sw.reset().start() + + final matchAllQuery = MATCH_ALL + + def userCount = fullTextIndexService.rawSearch(query, SearchType.COUNT, hitsCount) + def totalCount = fullTextIndexService.rawSearch(matchAllQuery, SearchType.COUNT, hitsCount) + def userPercent = String.format('%.2f', (userCount / totalCount) * 100) + + sw.stop() + log.info("notbookMainFragment.percentage ${sw.toString()}") + + sw.reset().start() + def fieldObservationQuery = simpleTemplateEngine.createTemplate(FIELD_OBSERVATIONS).make([userId: userInstance.userId]).toString() + def fieldObservationCount = fullTextIndexService.rawSearch(fieldObservationQuery, SearchType.COUNT, hitsCount) + + sw.stop() + log.info("notbookMainFragment.fieldObservationCount ${sw.toString()}") + + sw.reset().start() + final validatedQuery = simpleTemplateEngine.createTemplate(VALIDATED_TASKS_FOR_USER).make([userId: userInstance.userId]).toString() + def validatedCount = fullTextIndexService.rawSearch(validatedQuery, SearchType.COUNT, hitsCount) + sw.stop() + log.info("notbookMainFragment.validatedCount ${sw.toString()}") + + [userInstance: userInstance, expeditionCount: expeditions ? expeditions[0] : 0, score: score, + recentAchievements: recentAchievements, speciesList: speciesList2, fieldObservationCount: fieldObservationCount, + validatedCount: validatedCount, userPercent: userPercent, totalSpeciesCount: totalSpeciesCount + ] } def badgesFragment() { def userInstance = User.get(params.int("id")) - def achievements = achievementService.calculateAchievements(userInstance) + //def achievements = achievementService.calculateAchievements(userInstance) + def achievements = userInstance.achievementAwards + def sortedAchievements = achievements.sort { a,b -> b.awarded.compareTo(a.awarded) } def score = userService.getUserScore(userInstance) - def allAchievements = achievementService.getAllAchievements() + def awardedIds = achievements*.achievement*.id.toList() + def otherAchievements + if (awardedIds) otherAchievements = AchievementDescription.findAllByIdNotInListAndEnabled(awardedIds, true, [sort: 'name']) + else otherAchievements = AchievementDescription.findAllByEnabled(true, [sort: 'name']) - [userInstance: userInstance, achievements: achievements, score: score, allAchievements: allAchievements] + [userInstance: userInstance, achievements: sortedAchievements, score: score, allAchievements: otherAchievements] } def recentTasksFragment() { @@ -561,4 +718,16 @@ class UserController { [userInstance: userInstance] } + + def savedTasksFragment() { + def userInstance = User.get(params.int("id")) + + [userInstance: userInstance] + } + + def validatedTasksFragment() { + def userInstance = User.get(params.int("id")) + + [userInstance: userInstance] + } } diff --git a/grails-app/controllers/au/org/ala/volunteer/ValidateController.groovy b/grails-app/controllers/au/org/ala/volunteer/ValidateController.groovy index 2da05e790..d383efb6f 100644 --- a/grails-app/controllers/au/org/ala/volunteer/ValidateController.groovy +++ b/grails-app/controllers/au/org/ala/volunteer/ValidateController.groovy @@ -36,7 +36,7 @@ class ValidateController { def template = Template.findById(project.template.id) def isValidator = userService.isValidator(project) - logService.log(currentUser + " has role: ADMIN = " + userService.isAdmin() + " && VALIDATOR = " + isValidator) + log.info(currentUser + " has role: ADMIN = " + userService.isAdmin() + " && VALIDATOR = " + isValidator) if (taskInstance.fullyTranscribedBy && taskInstance.fullyTranscribedBy != currentUser && !(userService.isAdmin() || isValidator)) { isReadonly = "readonly" diff --git a/grails-app/domain/au/org/ala/volunteer/AchievementAward.groovy b/grails-app/domain/au/org/ala/volunteer/AchievementAward.groovy new file mode 100644 index 000000000..b01c5fe4d --- /dev/null +++ b/grails-app/domain/au/org/ala/volunteer/AchievementAward.groovy @@ -0,0 +1,22 @@ +package au.org.ala.volunteer + +class AchievementAward { + + Date awarded + boolean userNotified = false + + static belongsTo = [achievement: AchievementDescription, user: User] + + Long version + + Date dateCreated + Date lastUpdated + + static constraints = { + achievement unique: 'user' + } + + public String toString() { + "AchievementAward (${achievement}, ${user}, awarded: ${awarded}, userNotified: ${userNotified})" + } +} diff --git a/grails-app/domain/au/org/ala/volunteer/AchievementDescription.groovy b/grails-app/domain/au/org/ala/volunteer/AchievementDescription.groovy new file mode 100644 index 000000000..0ab31de34 --- /dev/null +++ b/grails-app/domain/au/org/ala/volunteer/AchievementDescription.groovy @@ -0,0 +1,88 @@ +package au.org.ala.volunteer + +import groovy.transform.ToString +import org.codehaus.groovy.control.CompilationFailedException + +class AchievementDescription { + + String name + String description + String badge + + boolean enabled = false + + // Discriminate with this instead of polymorphism + AchievementType type = AchievementType.ELASTIC_SEARCH_QUERY + + // full json? + // template string with task.id, task.???, task.fields.???, user.id, user.??? + // eg + /* +{ + "filtered" : { + "filter" : { + "range" : { + "dateFullyValidated" : { + "gt" : now-2M + } + } + } + } +} + */ + String searchQuery + Integer count + + String aggregationQuery + AggregationType aggregationType = AggregationType.CODE + + + // Should return boolean? + // Provide access to current user, updated task, Task / Field / ??? Repos? + String code + + static hasMany = [awards: AchievementAward] + + Long version + + Date dateCreated + Date lastUpdated + + static constraints = { + description maxSize: 1000 + code nullable: true, minSize: 0, maxSize: 10000, widget: 'textArea', validator: { val, obj, errors -> + if (obj.type != AchievementType.GROOVY_SCRIPT + && (obj.aggregationType != AggregationType.CODE) || obj.type != AchievementType.ELASTIC_SEARCH_AGGREGATION_QUERY) + return true + + if (!val || obj.count == null) return false + try { + new GroovyShell().parse(val) + return true + } catch (CompilationFailedException e) { + log.error("Compilation failed for groovy code:\n\n$val\n", e) + return false + } + } + searchQuery nullable: true, minSize: 0, maxSize: 10000, widget: 'textArea', validator: { val, obj, errors -> + if (obj.type != AchievementType.ELASTIC_SEARCH_QUERY) return true + if (!val) return false + } + count nullable: true, validator: { val, obj, errors -> + if (obj.type != AchievementType.ELASTIC_SEARCH_QUERY) return true + if (val == null || val < 0) return false + } + aggregationQuery nullable: true, minSize: 0, maxSize: 10000, widget: 'textArea', validator: { val, obj, errors -> + if (obj.type != AchievementType.ELASTIC_SEARCH_AGGREGATION_QUERY) return true + if (!val) return false + } + aggregationType nullable: true, validator: { val, obj, errors -> + if (obj.type != AchievementType.ELASTIC_SEARCH_AGGREGATION_QUERY) return true + if (val == null) return false + } + } + + public String toString() { + "AchievementDescription (id: $id, name: ${name})" + } +} diff --git a/grails-app/domain/au/org/ala/volunteer/Field.groovy b/grails-app/domain/au/org/ala/volunteer/Field.groovy index 01bca46fe..172d198f8 100644 --- a/grails-app/domain/au/org/ala/volunteer/Field.groovy +++ b/grails-app/domain/au/org/ala/volunteer/Field.groovy @@ -26,4 +26,23 @@ class Field implements Serializable { value nullable: true superceded nullable: true } + + // These events use a static method rather than an injected service + // to prevent issues with serialisation in webflows + + // Executed after an object is persisted to the database + def afterInsert() { + def taskId = this.task?.id + if (taskId) GormEventDebouncer.debounceTask(taskId) + } + // Executed after an object has been updated + def afterUpdate() { + def taskId = this.task?.id + if (taskId) GormEventDebouncer.debounceTask(taskId) + } + // Executed after an object has been deleted + def afterDelete() { + def taskId = this.task?.id + if (taskId) GormEventDebouncer.debounceTask(taskId) + } } diff --git a/grails-app/domain/au/org/ala/volunteer/Label.groovy b/grails-app/domain/au/org/ala/volunteer/Label.groovy new file mode 100644 index 000000000..226229710 --- /dev/null +++ b/grails-app/domain/au/org/ala/volunteer/Label.groovy @@ -0,0 +1,41 @@ +package au.org.ala.volunteer + +import groovy.transform.ToString + +@ToString() +class Label implements Serializable { + + Long id + String category + String value + + static belongsTo = Project + static hasMany = [projects: Project] + + static constraints = { + category unique: 'value' + } + + boolean equals(o) { + if (this.is(o)) return true + if (!(o instanceof Label)) return false + + Label label = (Label) o + + if (category != label.category) return false + if (value != label.value) return false + + return true + } + + int hashCode() { + int result + result = (category != null ? category.hashCode() : 0) + result = 31 * result + value.hashCode() + return result + } + + LinkedHashMap toMap() { + [id: id, category: category, value: value] + } +} diff --git a/grails-app/domain/au/org/ala/volunteer/Picklist.groovy b/grails-app/domain/au/org/ala/volunteer/Picklist.groovy index 4f476b6a8..958b10bc8 100644 --- a/grails-app/domain/au/org/ala/volunteer/Picklist.groovy +++ b/grails-app/domain/au/org/ala/volunteer/Picklist.groovy @@ -1,6 +1,6 @@ package au.org.ala.volunteer -class Picklist { +class Picklist implements Serializable { String name static mapping = { diff --git a/grails-app/domain/au/org/ala/volunteer/PicklistItem.groovy b/grails-app/domain/au/org/ala/volunteer/PicklistItem.groovy index 54cb6793b..397df07d5 100644 --- a/grails-app/domain/au/org/ala/volunteer/PicklistItem.groovy +++ b/grails-app/domain/au/org/ala/volunteer/PicklistItem.groovy @@ -1,6 +1,6 @@ package au.org.ala.volunteer -class PicklistItem { +class PicklistItem implements Serializable { Picklist picklist String key diff --git a/grails-app/domain/au/org/ala/volunteer/Project.groovy b/grails-app/domain/au/org/ala/volunteer/Project.groovy index 94452a6cd..66680f90f 100644 --- a/grails-app/domain/au/org/ala/volunteer/Project.groovy +++ b/grails-app/domain/au/org/ala/volunteer/Project.groovy @@ -27,7 +27,7 @@ class Project implements Serializable { def grailsLinkGenerator static belongsTo = [template: Template, projectType: ProjectType] - static hasMany = [tasks: Task, projectAssociations: ProjectAssociation, newsItems: NewsItem] + static hasMany = [tasks: Task, projectAssociations: ProjectAssociation, newsItems: NewsItem, labels: Label] static transients = ['featuredImage', 'grailsApplication', 'grailsLinkGenerator'] static mapping = { @@ -85,4 +85,9 @@ class Project implements Serializable { public void setFeaturedImage(String image) { // do nothing } + + // Executed after an object has been updated + def afterUpdate() { + GormEventDebouncer.debounceProject(this.id) + } } diff --git a/grails-app/domain/au/org/ala/volunteer/Task.groovy b/grails-app/domain/au/org/ala/volunteer/Task.groovy index e561d8535..979b7ca97 100644 --- a/grails-app/domain/au/org/ala/volunteer/Task.groovy +++ b/grails-app/domain/au/org/ala/volunteer/Task.groovy @@ -42,4 +42,19 @@ class Task implements Serializable { lastViewedBy nullable: true } + // These events use a static method rather than an injected service + // to prevent issues with serialisation in webflows + + // Executed after an object is persisted to the database + def afterInsert() { + GormEventDebouncer.debounceTask(this.id) + } + // Executed after an object has been updated + def afterUpdate() { + GormEventDebouncer.debounceTask(this.id) + } + // Executed after an object has been deleted + def afterDelete() { + GormEventDebouncer.debounceDeleteTask(this.id) + } } diff --git a/grails-app/domain/au/org/ala/volunteer/User.groovy b/grails-app/domain/au/org/ala/volunteer/User.groovy index 6c6be403f..130cbf389 100644 --- a/grails-app/domain/au/org/ala/volunteer/User.groovy +++ b/grails-app/domain/au/org/ala/volunteer/User.groovy @@ -9,7 +9,7 @@ class User { Integer validatedCount = 0 // the number of task completed by this user and then validated by a validator Date created //set to the date when the user first contributed - static hasMany = [userRoles:UserRole] + static hasMany = [userRoles:UserRole, achievementAwards: AchievementAward] static mapping = { table 'vp_user' @@ -58,4 +58,8 @@ class User { int hashCode() { Objects.hash(userId) } + + public String toString() { + "User (id: $id, userId: ${userId}, displayName: ${displayName})" + } } diff --git a/grails-app/i18n/messages.properties b/grails-app/i18n/messages.properties index f50c306ce..5b3edd748 100644 --- a/grails-app/i18n/messages.properties +++ b/grails-app/i18n/messages.properties @@ -61,3 +61,8 @@ typeMismatch.java.math.BigInteger=Property {0} must be a valid number default.projects.label=Expeditions newsItem.projectOrInstitutionRequired=An expedition or insitution is required. + +achievementDescription.label=Badge + +achievement.awarded.message={0} awarded to {1} +achievement.removed.message={0} removed from {1} \ No newline at end of file diff --git a/grails-app/jobs/au/org/ala/volunteer/TaskIndexerJob.groovy b/grails-app/jobs/au/org/ala/volunteer/TaskIndexerJob.groovy index fc3238412..964a2e606 100644 --- a/grails-app/jobs/au/org/ala/volunteer/TaskIndexerJob.groovy +++ b/grails-app/jobs/au/org/ala/volunteer/TaskIndexerJob.groovy @@ -2,7 +2,8 @@ package au.org.ala.volunteer class TaskIndexerJob { - def fullTextIndexService + def domainUpdateService + //def fullTextIndexService def grailsApplication def concurrent = false @@ -12,9 +13,10 @@ class TaskIndexerJob { def execute() { try { - fullTextIndexService.processIndexTaskQueue() + domainUpdateService.processTaskQueue() + //fullTextIndexService.processIndexTaskQueue() } catch (Exception ex) { - ex.printStackTrace() + log.error("Exception while processing task queue!", ex) } } diff --git a/grails-app/services/au/org/ala/volunteer/AchievementService.groovy b/grails-app/services/au/org/ala/volunteer/AchievementService.groovy index fa8dc6566..aac020a88 100644 --- a/grails-app/services/au/org/ala/volunteer/AchievementService.groovy +++ b/grails-app/services/au/org/ala/volunteer/AchievementService.groovy @@ -1,12 +1,120 @@ package au.org.ala.volunteer +import com.google.common.io.Closer +import grails.gorm.DetachedCriteria +import groovy.text.SimpleTemplateEngine +import org.elasticsearch.action.search.SearchResponse +import org.elasticsearch.action.search.SearchType +import org.grails.plugins.metrics.groovy.Timed + +import java.nio.file.DirectoryStream +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + class AchievementService { static transactional = true def taskService - def logService def grailsApplication + def fullTextIndexService + def grailsLinkGenerator + + @Timed + def evalAndRecordAchievementsForUser(User user, Long taskId) { + def alreadyAwarded = AchievementAward.withCriteria { + eq 'user', user + projections { + property 'achievement.id' + } + } + final achievements = alreadyAwarded ? + AchievementDescription.findAllByIdNotInListAndEnabled(alreadyAwarded, true) + : AchievementDescription.findAllByEnabled(true) + + achievements + .find { evaluateAchievement(it, user, taskId)} + .collect { + log.info("${user.id} (${user.email} achieved ${it.name}") + new AchievementAward(achievement: it, user: user, awarded: new Date()) + }*.save(true) + } + + @Timed + def evalAndRecordAchievements(Set userIds, Long taskId) { + evalAndRecordAchievements(User.findAllByUserIdInList(userIds.toList()), taskId) + } + + def evalAndRecordAchievements(List users, Long taskId) { + users.collectEntries { user -> + def cheevs = evalAndRecordAchievementsForUser(user, taskId) + [(user.userId): cheevs] + } + } + + @Timed + def evaluateAchievement(AchievementDescription cheev, User user, Long taskId) { + switch (cheev.type) { + case AchievementType.ELASTIC_SEARCH_QUERY: + return evaluateElasticSearchAchievement(cheev, user, taskId) + case AchievementType.GROOVY_SCRIPT: + return evaluateGroovyAchievement(cheev, user, taskId) + case AchievementType.ELASTIC_SEARCH_AGGREGATION_QUERY: + return evaluateElasticSearchAggregationAchievement(cheev, user, taskId) + } + } + + @Timed + def evaluateElasticSearchAggregationAchievement(AchievementDescription achievementDescription, User user, Long taskId) { + final template = achievementDescription.searchQuery + final aggTemplate = achievementDescription.aggregationQuery + + final code = achievementDescription.code + + final count = achievementDescription.count + final aggType = achievementDescription.aggregationType + + final binding = ["userId":user.userId, "taskId":taskId] + + def engine = new SimpleTemplateEngine() + def query = engine.createTemplate(template).make(binding) + + def agg = engine.createTemplate(aggTemplate).make(binding) + + final closure + if (aggType == AggregationType.CODE) { + closure = {SearchResponse sr -> + def script = new GroovyShell().parse(code) + script.setBinding(new Binding([searchResponse: sr, taskId: taskId, user: user])) + return script.run() + } + } else { + closure = fullTextIndexService.aggregationHitsGreaterThan(count, aggType) + } + fullTextIndexService.rawSearch(query.toString(), SearchType.COUNT, agg.toString(), closure) + } + + @Timed + private def evaluateGroovyAchievement(AchievementDescription achievementDescription, User user, Long taskId) { + final code = achievementDescription.code + def script = new GroovyShell().parse(code) + script.setBinding(new Binding([applicationContext: grailsApplication.mainContext, taskId: taskId, user: user])) + return script.run() + } + + @Timed + private def evaluateElasticSearchAchievement(AchievementDescription achievementDescription, User user, Long taskId) { + final template = achievementDescription.searchQuery + final count = achievementDescription.count + + final binding = ["userId":user.userId, "taskId":taskId] + + def engine = new SimpleTemplateEngine() + def query = engine.createTemplate(template).make(binding) + + fullTextIndexService.rawSearch(query.toString(), SearchType.COUNT, fullTextIndexService.searchResponseHitsGreaterThan(count)) + } def getAllAchievements() { def achievements = grailsApplication.config.achievements; @@ -32,18 +140,17 @@ class AchievementService { def rule = this.metaClass.properties.find() { it.name == desc.name + "_rule" } if (rule) { - logService.log "Checking rule for achievement ${desc.name}" - AchievementRuleResult result = rule.getProperty(this)(user, tasks); + log.debug "Checking rule for achievement ${desc.name}" + AchievementRuleResult result = rule.getProperty(this)(user, tasks) if (result && result.success) { - // TODO Get email from user details service - logService.log "${user.userId} (${user.email}) just achieved ${desc.name}!" + log.info "${user.userId} (${user.email}) just achieved ${desc.name}!" Date dateAchieved = result.dateAchieved ?: new Date(); def newAchievement = new Achievement( name: desc.name, user: user, dateAchieved: dateAchieved) newAchievement.save(flush: true, failOnError: true) ach = newAchievement } } else { - logService.log "Rule for achievement ${desc.name} not found!" + log.warn "Rule for achievement ${desc.name} not found!" } } @@ -89,5 +196,61 @@ class AchievementService { return new AchievementRuleResult( success: projects.size() >= 7, dateAchieved: null ); } + String getBadgeImageUrlPrefix() { + "${grailsApplication.config.server.url}/${grailsApplication.config.images.urlPrefix}achievements/" + } + + String getBadgeImageFilePrefix() { + "${grailsApplication.config.images.home}/achievements/" + } + + String getBadgeImagePath(AchievementDescription achievementDescription) { + def prefix = badgeImageFilePrefix + return "${prefix}${achievementDescription.badge}" + } + + boolean hasBadgeImage(AchievementDescription achievementDescription) { + def f = new File(getBadgeImagePath(achievementDescription)) + return f.exists() + } + + public String getBadgeImageUrl(AchievementDescription achievementDescription) { + def prefix = badgeImageUrlPrefix + if (hasBadgeImage(achievementDescription)) { + return "${prefix}${achievementDescription.badge}" + } else { + return grailsLinkGenerator.resource([dir: '/images/achievements', file: 'blank.png']) + } + } + + def cleanImageDir(List badges) { + + def stream + def c = Closer.create() + try { + stream = c.register(Files.newDirectoryStream(Paths.get(badgeImageFilePrefix), { Path path -> !badges.contains(path.fileName.toString()) } as DirectoryStream.Filter)) + + for (Path path : stream) { + try { Files.delete(path) } catch (e) { log.warn("Couldn't delete ${path}", e) } + } + } catch (e) { + log.error("Error with deleting unused achievement badges", e) + } finally { + c.close() + } + } + + @Timed + List newAchievementsForUser(User user) { + AchievementAward.findAllByUserAndUserNotified(user, false) + } + + def markAchievementsViewed(List ids) { + def criteria = new DetachedCriteria(AchievementAward).build { + inList 'id', ids + } + int total = criteria.updateAll(userNotified:true) + log.info("Marked ${total} achievements as seen") + } } diff --git a/grails-app/services/au/org/ala/volunteer/CollectionEventService.groovy b/grails-app/services/au/org/ala/volunteer/CollectionEventService.groovy index 87cf10b19..77b98d2d8 100644 --- a/grails-app/services/au/org/ala/volunteer/CollectionEventService.groovy +++ b/grails-app/services/au/org/ala/volunteer/CollectionEventService.groovy @@ -72,7 +72,7 @@ class CollectionEventService { def rowsProcessed = 0; def rowsDeleted = CollectionEvent.executeUpdate("delete CollectionEvent where institutionCode = '${institutionCode}'") - logService.log "${rowsDeleted} rows deleted from CollectionEvent table" + log.info "${rowsDeleted} rows deleted from CollectionEvent table" Map col =[:] @@ -132,7 +132,7 @@ class CollectionEventService { sessionFactory.currentSession.flush() sessionFactory.currentSession.clear() propertyInstanceMap.get().clear() - logService.log "${rowsProcessed} rows processed, ${count} rows imported..." + log.info "${rowsProcessed} rows processed, ${count} rows imported..." } } diff --git a/grails-app/services/au/org/ala/volunteer/DomainUpdateService.groovy b/grails-app/services/au/org/ala/volunteer/DomainUpdateService.groovy new file mode 100644 index 000000000..45bd0dc49 --- /dev/null +++ b/grails-app/services/au/org/ala/volunteer/DomainUpdateService.groovy @@ -0,0 +1,160 @@ +package au.org.ala.volunteer + +import groovy.transform.ToString +import org.springframework.web.context.request.RequestContextHolder + +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicInteger + +//@Transactional(readOnly = true) +class DomainUpdateService { + + def grailsApplication + def fullTextIndexService + def achievementService + def settingsService + def userService + + private static ConcurrentLinkedQueue _backgroundQueue = new ConcurrentLinkedQueue() + // Used to show the currently processing size, which is the size of the background queue + the number of + // tasks that have been removed from the queue but not yet completed + private AtomicInteger currentlyProcessing = new AtomicInteger(0) + + def onTasksDeleted(Set deletedTasks) { + if (deletedTasks) { + fullTextIndexService.deleteTasks(deletedTasks) + } + } + + def onTasksUpdated(Set taskSet) { + def cheevs = [] + if (taskSet) { + fullTextIndexService.indexTasks(taskSet) + postIndexTaskActions(taskSet) + } + return cheevs + } + + private def postIndexTaskActions(Set taskSet) { + def cheevs = [] + if (settingsService.getSetting(SettingDefinition.EnableAchievementCalculations)) { + // TODO Replace with withCriteria + def involvedUserIds = + Task.findAllByIdInList(taskSet.toList()) + .collect { [it.fullyTranscribedBy, it.fullyValidatedBy] } + .flatten().findAll { it != null } + .toSet() + def currentUserId + if (RequestContextHolder.requestAttributes) { + currentUserId = userService.currentUserId + } else { + log.info("Not currently in a request context, there is no current user to add to achivement evaluation.") + } + if (currentUserId) { + involvedUserIds.add(currentUserId) + } + cheevs = taskSet.collect { + achievementService.evalAndRecordAchievements(involvedUserIds, it) + }.flatten() + } + taskSet.clear() + cheevs + } + + def static scheduleProjectUpdate(long id) { + _backgroundQueue.add(new UpdateProjectTask(projectId: id)) + } + + def static scheduleTaskUpdate(long id) { + _backgroundQueue.add(new UpdateTaskTask(taskId: id)) + } + + def static scheduleTaskIndex(Task task) { + _backgroundQueue.add(new IndexTaskTask(taskId: task.id)) + } + + def static scheduleTaskIndex(long taskId) { + _backgroundQueue.add(new IndexTaskTask(taskId: taskId)) + } + + static def scheduleTaskDeleteIndex(long taskId) { + _backgroundQueue.add(new DeleteTaskTask(taskId: taskId)) + } + + + def getQueueLength() { + return _backgroundQueue.size() + currentlyProcessing.get() + } + + def processTaskQueue(int maxTasks = 10000) { + int taskCount = 0 + QueueTask jobDescriptor = null + + Set deletes = new HashSet<>() + Set updates = new HashSet<>() + Set indexes = new HashSet<>() + + while (taskCount < maxTasks && (jobDescriptor = _backgroundQueue.poll()) != null) { + if (jobDescriptor) { + switch (jobDescriptor) { + case DeleteTaskTask: + deletes.add(jobDescriptor.taskId) + //fullTextIndexService.deleteTask(jobDescriptor.taskId) + //taskCount++ // deletes for free + break + case UpdateTaskTask: + updates.add(jobDescriptor.taskId) + indexes.add(jobDescriptor.taskId) + taskCount++ + break + case IndexTaskTask: + indexes.add(jobDescriptor.taskId) + taskCount++ + //Task t = Task.get(jobDescriptor.taskId) + //if (t) { + //fullTextIndexService.indexTask(t) + //} + break + case UpdateProjectTask: + def tasks = //Task.findAllByProjectId(jobDescriptor.projectId) + Task.withCriteria { + project { + eq 'id', jobDescriptor.projectId + } + projections { + property 'id' + } + } + updates.addAll(tasks) + indexes.addAll(tasks) + taskCount+= tasks.size + break + default: + log.warn("Unrecognised object ${jobDescriptor} on queue") + } + } + } + + currentlyProcessing.set(indexes.size()) + + if (deletes) fullTextIndexService.deleteTasks(deletes) + if (indexes) fullTextIndexService.indexTasks(indexes) { currentlyProcessing.decrementAndGet() } + if (updates) postIndexTaskActions(updates) + } +} + +public abstract class QueueTask { } + +@ToString +public class UpdateProjectTask extends QueueTask { public long projectId } + +public abstract class QueueTaskTask extends QueueTask { public long taskId } + +@ToString +public class DeleteTaskTask extends QueueTaskTask { } + +@ToString +public class UpdateTaskTask extends QueueTaskTask{ } + +@ToString +public class IndexTaskTask extends QueueTaskTask { } diff --git a/grails-app/services/au/org/ala/volunteer/EmailService.groovy b/grails-app/services/au/org/ala/volunteer/EmailService.groovy index 7cd7f9a52..fe5f42d33 100644 --- a/grails-app/services/au/org/ala/volunteer/EmailService.groovy +++ b/grails-app/services/au/org/ala/volunteer/EmailService.groovy @@ -19,7 +19,7 @@ class EmailService { * @return The mail message */ def sendMail(String emailAddress, String subj, String message) { - logService.log("Sending email to ${emailAddress} - ${subj}") + log.info("Sending email to ${emailAddress} - ${subj}") mailService.sendMail { to emailAddress from "noreply@volunteer.ala.org.au" @@ -38,7 +38,7 @@ class EmailService { * @param message The message body */ def pushMessageOnQueue(String emailAddress, String subject, String message) { - logService.log("Queuing email message to ${emailAddress} - ${subject}") + log.info("Queuing email message to ${emailAddress} - ${subject}") def qmsg = new QueuedEmailMessage(emailAddress: emailAddress, subject: subject, message: message) _queuedMessages.add(qmsg) } diff --git a/grails-app/services/au/org/ala/volunteer/FieldSyncService.groovy b/grails-app/services/au/org/ala/volunteer/FieldSyncService.groovy index 460353a26..132a6a4ca 100644 --- a/grails-app/services/au/org/ala/volunteer/FieldSyncService.groovy +++ b/grails-app/services/au/org/ala/volunteer/FieldSyncService.groovy @@ -61,7 +61,7 @@ class FieldSyncService { } def value = distinctValues.join(",") - logService.log("WARNING: Duplicate field values detected for task: ${task?.id} field: ${fieldname} values: ${value}") + log.warn("WARNING: Duplicate field values detected for task: ${task?.id} field: ${fieldname} values: ${value}") return value } @@ -197,7 +197,8 @@ class FieldSyncService { task.save(flush: true, failOnError: true) - fullTextIndexService.scheduleTaskIndex(task) + // Should be dealt with by GORM event + //DomainUpdateService.scheduleTaskIndex(task) } } diff --git a/grails-app/services/au/org/ala/volunteer/ForumNotifierService.groovy b/grails-app/services/au/org/ala/volunteer/ForumNotifierService.groovy index 2dbe11e04..8b88f6240 100644 --- a/grails-app/services/au/org/ala/volunteer/ForumNotifierService.groovy +++ b/grails-app/services/au/org/ala/volunteer/ForumNotifierService.groovy @@ -95,7 +95,7 @@ class ForumNotifierService { try { if (FrontPage.instance().enableForum && settingsService.getSetting(SettingDefinition.ForumNotificationsEnabled)) { def interestedUsers = getUsersInterestedInTopic(topic) - logService.log("Sending notifications to users watching topic ${topic.id}: " + interestedUsers.collect { userService.detailsForUserId(it.userId).email }) + log.info("Sending notifications to users watching topic ${topic.id}: " + interestedUsers.collect { userService.detailsForUserId(it.userId).email }) def message = customPageRenderer.render(view: '/forum/topicNotificationMessage', model: [messages: lastMessage]) def appName = messageSource.getMessage("default.application.name", null, "DigiVol", LocaleContextHolder.locale) interestedUsers.each { user -> @@ -103,7 +103,7 @@ class ForumNotifierService { } } } catch (Throwable ex) { - logService?.log("Exception occurred sending notifications: " + ex.message) + log.error("Exception occurred sending notifications: ", e) } } @@ -111,7 +111,7 @@ class ForumNotifierService { try { if (FrontPage.instance().enableForum && settingsService.getSetting(SettingDefinition.ForumNotificationsEnabled)) { def interestedUsers = getModeratorsForTopic(topic) - logService.log("Sending notifications to moderators for new topic ${topic.id}: " + userService.getEmailAddressesForIds(interestedUsers*.userId)) + log.info("Sending notifications to moderators for new topic ${topic.id}: " + userService.getEmailAddressesForIds(interestedUsers*.userId)) def message = customPageRenderer.render(view: '/forum/newTopicNotificationMessage', model: [messages: firstMessage]) def appName = messageSource.getMessage("default.application.name", null, "DigiVol", LocaleContextHolder.locale) interestedUsers.each { user -> @@ -119,34 +119,35 @@ class ForumNotifierService { } } } catch (Throwable ex) { - logService?.log("Exception occurred sending notifications: " + ex.message) + log.error("Exception occurred sending notifications: ", e) } } def processPendingNotifications() { // Only process notifications if the forum is enabled... if (FrontPage.instance().enableForum && settingsService.getSetting(SettingDefinition.ForumNotificationsEnabled)) { - logService.log("Processing Forum Message Notifications") + log.info("Processing Forum Message Notifications") def messageList = ForumTopicNotificationMessage.list() if (messageList) { def userMap = messageList.groupBy { it.user } - logService.log("Forum Topic Notification Sender: ${messageList.size()} message(s) found across ${userMap.keySet().size()} user(s).") + log.info("Forum Topic Notification Sender: ${messageList.size()} message(s) found across ${userMap.keySet().size()} user(s).") def appName = messageSource.getMessage("default.application.name", null, "DigiVol", LocaleContextHolder.locale) userMap.keySet().each { user -> - // TODO Get email from userdetails service - logService.log("Processing messages for ${user.userId} (${userService.detailsForUserId(user.userId).email}) ...") + def email = user.email try { + email = userService.detailsForUserId(user.userId).email + log.info("Processing messages for ${user.userId} (${email}) ...") def messages = userMap[user]?.sort { it.message.date } def message = customPageRenderer.render(view: '/forum/topicNotificationMessage', model: [messages: messages]) - emailService.sendMail(userService.detailsForUserId(user.userId).email, "${appName} Forum notification", message) + emailService.sendMail(email, "${appName} Forum notification", message) } catch (Exception ex) { // TODO Get email from userdetails service - logService.log("Failed to send email to ${user.userId} (${user.email}): " + ex.message) + log.error("Failed to send email to ${user.userId} (${email}): ", e) } } // now clean up the notification list - logService.log("Purging notification list") + log.debug("Purging notification list") messageList.each { it.delete() } diff --git a/grails-app/services/au/org/ala/volunteer/ForumService.groovy b/grails-app/services/au/org/ala/volunteer/ForumService.groovy index 252354c98..3652f8889 100644 --- a/grails-app/services/au/org/ala/volunteer/ForumService.groovy +++ b/grails-app/services/au/org/ala/volunteer/ForumService.groovy @@ -180,7 +180,7 @@ class ForumService { if (settingsService.getSetting(SettingDefinition.BatchForumNotificationMessages)) { // Do the notifications asynchronously def interestedUsers = forumNotifierService.getUsersInterestedInTopic(topic) - logService.log("Interested users in topic ${topic.id}: " + interestedUsers.collect { it.userId }) + log.info("Interested users in topic ${topic.id}: " + interestedUsers.collect { it.userId }) interestedUsers.each { user -> def message = new ForumTopicNotificationMessage(user: user, topic: topic, message: lastMessage) message.save(failOnError: true) @@ -199,7 +199,7 @@ class ForumService { if (settingsService.getSetting(SettingDefinition.BatchForumNotificationMessages)) { // Do the notifications asynchronously def interestedUsers = forumNotifierService.getModeratorsForTopic(topic) - logService.log("Interested users in topic ${topic.id}: " + interestedUsers.collect { it.userId }) + log.info("Interested users in topic ${topic.id}: " + interestedUsers.collect { it.userId }) interestedUsers.each { user -> def message = new ForumTopicNotificationMessage(user: user, topic: topic, message: firstMessage) message.save(failOnError: true) diff --git a/grails-app/services/au/org/ala/volunteer/FullTextIndexService.groovy b/grails-app/services/au/org/ala/volunteer/FullTextIndexService.groovy index d8a1b6c30..d43d2c68c 100644 --- a/grails-app/services/au/org/ala/volunteer/FullTextIndexService.groovy +++ b/grails-app/services/au/org/ala/volunteer/FullTextIndexService.groovy @@ -1,35 +1,41 @@ package au.org.ala.volunteer +import grails.async.Promises import grails.converters.JSON import grails.transaction.NotTransactional +import grails.transaction.Transactional import groovy.json.JsonSlurper -import org.codehaus.groovy.grails.web.servlet.mvc.GrailsParameterMap +import org.codehaus.groovy.grails.orm.hibernate.HibernateSession +import org.codehaus.groovy.grails.web.json.JSONObject import org.elasticsearch.action.delete.DeleteResponse import org.elasticsearch.action.index.IndexResponse import org.elasticsearch.action.search.SearchRequestBuilder import org.elasticsearch.action.search.SearchResponse import org.elasticsearch.action.search.SearchType import org.elasticsearch.client.Client +import org.elasticsearch.client.Requests import org.elasticsearch.common.settings.ImmutableSettings +import org.elasticsearch.common.xcontent.ToXContent +import org.elasticsearch.common.xcontent.XContentBuilder +import org.elasticsearch.common.xcontent.XContentFactory import org.elasticsearch.index.query.FilterBuilder import org.elasticsearch.search.sort.SortOrder +import org.grails.plugins.metrics.groovy.Timed +import org.hibernate.FetchMode import javax.annotation.PostConstruct import javax.annotation.PreDestroy -import java.util.concurrent.ConcurrentLinkedQueue import static org.elasticsearch.node.NodeBuilder.nodeBuilder import org.elasticsearch.node.Node +@Transactional(readOnly = true) class FullTextIndexService { public static final String INDEX_NAME = "digivol" public static final String TASK_TYPE = "task" - private static Queue _backgroundQueue = new ConcurrentLinkedQueue() - - def logService def grailsApplication private Node node @@ -38,13 +44,13 @@ class FullTextIndexService { @NotTransactional @PostConstruct def initialize() { - logService.log("ElasticSearch service starting...") + log.info("ElasticSearch service starting...") ImmutableSettings.Builder settings = ImmutableSettings.settingsBuilder(); settings.put("path.home", grailsApplication.config.elasticsearch.location); node = nodeBuilder().local(true).settings(settings).node(); client = node.client(); client.admin().cluster().prepareHealth().setWaitForYellowStatus().execute().actionGet(); - logService.log("ElasticSearch service initialisation complete.") + log.info("ElasticSearch service initialisation complete.") } @PreDestroy @@ -54,6 +60,14 @@ class FullTextIndexService { } } + def getIndexerQueueLength() { + return _backgroundQueue.size() + } + + def processIndexTaskQueue(int maxTasks = 10000) { + + } + public reinitialiseIndex() { try { def ct = new CodeTimer("Index deletion") @@ -61,68 +75,57 @@ class FullTextIndexService { ct.stop(true) } catch (Exception ex) { - println ex + log.warn("Failed to delete index - maybe because it didn't exist?", ex) // failed to delete index - maybe because it didn't exist? } addMappings() } - def scheduleTaskIndex(Task task) { - def job = new IndexTaskTask(taskId: task.id) - _backgroundQueue.add(job) - } + @Timed + def indexTask(Task task) { - def scheduleTaskIndex(long taskId) { - def job = new IndexTaskTask(taskId: taskId) - _backgroundQueue.add(job) - } + //def ct = new CodeTimer("Indexing task ${task.id}") - def getIndexerQueueLength() { - return _backgroundQueue.size() - } - - def processIndexTaskQueue(int maxTasks = 10000) { - int taskCount = 0 - IndexTaskTask jobDescriptor = null - - while (taskCount < maxTasks && (jobDescriptor = _backgroundQueue.poll()) != null) { - if (jobDescriptor) { - Task t = Task.get(jobDescriptor.taskId) - if (t) { - indexTask(t) - } - taskCount++ - } - } + LinkedHashMap data = esObjectFromTask(task) + return indexEsObject(data) + //ct.stop(true) } - def indexTask(Task task) { - - def ct = new CodeTimer("Indexing task ${task.id}") - - + private LinkedHashMap esObjectFromTask(Task task) { def data = [ - id: task.id, - projectid: task.project.id, - externalIdentifier: task.externalIdentifier, - externalUrl: task.externalUrl, - fullyTranscribedBy: task.fullyTranscribedBy, - dateFullyTranscribed: task.dateFullyTranscribed, - fullyValidatedBy: task.fullyValidatedBy, - dateFullyValidated: task.dateFullyValidated, - isValid: task.isValid, - created: task.created, - lastViewed: task.lastViewed ? new Date(task.lastViewed) : null, - lastViewedBy: task.lastViewedBy, - fields: [], - project:[ - projectType: task.project.projectType.toString(), - institution: task.project.institution ? task.project.institution.name : task.project.featuredOwner, - name: task.project.featuredLabel - ] + id : task.id, + projectid : task.project.id, + externalIdentifier : task.externalIdentifier, + externalUrl : task.externalUrl, + fullyTranscribedBy : task.fullyTranscribedBy, + dateFullyTranscribed: task.dateFullyTranscribed, + fullyValidatedBy : task.fullyValidatedBy, + dateFullyValidated : task.dateFullyValidated, + isValid : task.isValid, + created : task.created, + lastViewed : task.lastViewed ? new Date(task.lastViewed) : null, + lastViewedBy : task.lastViewedBy, + version : task.version, + fields : [], + project : [ + projectType : task.project.projectType.toString(), + institution : task.project.institution ? task.project?.institution?.name : task.project.featuredOwner, + institutionCollectoryId: task.project.institution?.collectoryUid, + harvestableByAla : task.project.harvestableByAla, + name : task.project.featuredLabel, + templateName : task.project.template?.name, + templateViewName : task.project.template?.viewName, + labels : task.project.labels?.collect { + [category: it.category, value: it.value] + } ?: [] + ] ] + if (task.project.mapInitLatitude && task.project.mapInitLongitude) { + data.project.put('mapRef', [lat: task.project.mapInitLatitude, lon: task.project.mapInitLongitude]) + } + def c = Field.createCriteria() def fields = c { eq("task", task) @@ -134,25 +137,39 @@ class FullTextIndexService { data.fields << [fieldid: field.id, name: field.name, recordIdx: field.recordIdx, value: field.value, transcribedByUserId: field.transcribedByUserId, validatedByUserId: field.validatedByUserId, updated: field.updated, created: field.created] } } + return data + } + private IndexResponse indexEsObject(LinkedHashMap data) { def json = (data as JSON).toString() - IndexResponse response = client.prepareIndex(INDEX_NAME, TASK_TYPE, task.id.toString()).setSource(json).execute().actionGet(); - - ct.stop(true) + def indexBuilder = client.prepareIndex(INDEX_NAME, TASK_TYPE, data.id.toString()).setSource(json) + if (data.version) indexBuilder.setVersion(data.version) + IndexResponse response = indexBuilder.execute().actionGet(); + return response } - def deleteTask(Task task) { - if (task) { - DeleteResponse response = client.prepareDelete(INDEX_NAME, TASK_TYPE, task.id.toString()).execute().actionGet(); + @Timed + List deleteTasks(Collection taskIds) { + taskIds.collect { + def dr = deleteTask(it) + if (dr.found) + log.info("${dr.id} deleted from index") + else + log.warn("${dr.id} not found in index") } } + + DeleteResponse deleteTask(Long taskId) { + client.prepareDelete(INDEX_NAME, TASK_TYPE, taskId.toString()).execute().actionGet(); + } - public QueryResults simpleTaskSearch(String query, GrailsParameterMap params) { + public QueryResults simpleTaskSearch(String query, Integer offset = null, Integer max = null, String sortBy = null, SortOrder sortOrder = null) { def qmap = [query: [filtered: [query:[query_string: [query: query?.toLowerCase()]]]]] - return search(qmap, params) + return search(qmap, offset, max, sortBy, sortOrder) } - public QueryResults search(Map query, GrailsParameterMap params) { + @Timed + public QueryResults search(Map query, Integer offset, Integer max, String sortBy, SortOrder sortOrder) { Map qmap = null Map fmap = null if (query.query) { @@ -174,25 +191,173 @@ class FullTextIndexService { b.setPostFilter(fmap) } - return executeSearch(b, params) + return executeSearch(b, offset, max, sortBy, sortOrder) + } + + public V rawSearch(String json, SearchType searchType, Closure resultClosure) { + rawSearch(json, searchType, null, null, null, null, null, resultClosure) + } + + + public V rawSearch(String json, SearchType searchType, String aggregation, Closure resultClosure) { + rawSearch(json, searchType, aggregation, null, null, null, null, resultClosure) + } + + public V rawSearch(String json, SearchType searchType, Integer max, Closure resultClosure) { + rawSearch(json, searchType, null, null, max, null, null, resultClosure) + } + + @Timed + public V rawSearch(String json, SearchType searchType, String aggregation, Integer offset, Integer max, String sortBy, SortOrder sortOrder, Closure resultClosure) { + + def queryMap = jsonStringToJSONObject(json) + + def b = client.prepareSearch(INDEX_NAME).setSearchType(searchType) + + Requests.searchRequest(INDEX_NAME).source(json) + b.setQuery(queryMap) + if (aggregation) { + def aggMap = jsonStringToJSONObject(aggregation) + b.setAggregations(aggMap) + } + + return executeGenericSearch(b, offset, max, sortBy, sortOrder, resultClosure) + } + + private JSONObject jsonStringToJSONObject(String json) { + def map = JSON.parse(json) + + if (map instanceof JSONObject) { + return map + } + throw new IllegalArgumentException("json must be a JSON object") + } + + Closure elasticSearchToJsonString = { ToXContent toXContent -> + XContentBuilder builder = XContentFactory.jsonBuilder() + builder.startObject().humanReadable(true) + toXContent.toXContent(builder, ToXContent.EMPTY_PARAMS); + builder.endObject().flush().string() + } + + Closure searchResponseHitsGreaterThan(long count) { + { SearchResponse searchResponse -> searchResponse.hits.totalHits() > count } + } + + Closure aggregationHitsGreaterThan(long count, AggregationType type) { + def closure; + switch (type) { + case AggregationType.ALL_MATCH: + closure = { SearchResponse searchResponse -> true } + break + case AggregationType.ANY_MATCH: + closure = { SearchResponse searchResponse -> true } + break + default: + throw new RuntimeException("aggregationHitsGreaterThan(count,type) can't be applied to type ${type}") + } + return closure + } + + static Closure hitsCount = { + SearchResponse searchResponse -> searchResponse.hits.totalHits } + + static Closure rawResponse = { it } def addMappings() { + def mappingJson = ''' - { - "mappings": { - "task": { - "dynamic_templates": [ - ], - "_all": { - "enabled": true, - "store": "yes" - }, - "properties": { - } +{ + "mappings": { + "task": { + "dynamic_templates": [ + ], + "_all": { + "enabled": true, + "store": "yes" + }, + "properties": { + "id" : {"type" : "long"}, + "projectId" : {"type" : "long"}, + "externalIdentifier" : {"type" : "string", "index": "not_analyzed" }, + "externalUrl" : {"type" : "string", "index": "not_analyzed"}, + "fullyTranscribedBy" : {"type" : "string", "index": "not_analyzed"}, + "dateFullyTranscribed" : {"type" : "date"}, + "fullyValidatedBy" : {"type" : "string", "index": "not_analyzed"}, + "dateFullyValidated" : {"type" : "date"}, + "isValid" : {"type" : "boolean"}, + "created" : {"type" : "date"}, + "lastViewed" : {"type" : "date"}, + "lastViewedBy" : {"type" : "string", "index": "not_analyzed"}, + "fields" : { + "type" : "nested", + "include_in_parent": true, + "properties": { + "fieldid" : {"type": "long" }, + "name" : { "type": "string", "index": "not_analyzed" }, + "recordIdx" : {"type": "integer" }, + "value" : { + "type": "string", + "index": "not_analyzed", + "fields": { + "analyzed": { + "type": "string", + "index": "analyzed", + "analyzer": "snowball" + } + } + }, + "transcribedByUserId": {"type": "string", "index": "not_analyzed" }, + "validatedByUserId": {"type": "string", "index": "not_analyzed" }, + "updated" : {"type" : "date"}, + "created" : {"type" : "date"} + } + }, + "project" : { + "type" : "object", + "properties" : { + "name" : { + "type": "string", + "index": "not_analyzed", + "fields": { + "analyzed": { + "type": "string", + "index": "analyzed", + "analyzer": "snowball" + } + } + }, + "projectType" : { "type" : "string", "index": "not_analyzed" }, + "institution" : { + "type": "string", + "index": "not_analyzed", + "fields": { + "analyzed": { + "type": "string", + "index": "analyzed" } + } + }, + "institutionCollectoryId": { "type" : "string", "index": "not_analyzed" }, + "harvestableByAla": { "type" : "boolean" }, + "mapRef": { "type": "geo_point", "lat_lon": true }, + "templateName" : { "type" : "string", "index": "not_analyzed"}, + "templateViewName" : { "type" : "string", "index": "not_analyzed"}, + "labels" : { + "type" : "nested", + "include_in_parent": true, + "properties": { + "category" : { "type": "string", "index": "not_analyzed" }, + "value" : { "type": "string", "index": "not_analyzed" } + } } + } } + } + } + } +} ''' def parsedJson = new JsonSlurper().parseText(mappingJson) @@ -203,46 +368,90 @@ class FullTextIndexService { } - private QueryResults executeFilterSearch(FilterBuilder filterBuilder, GrailsParameterMap params) { + @Timed + private QueryResults executeFilterSearch(FilterBuilder filterBuilder, Integer offset, Integer max, String sortBy, SortOrder sortOrder) { def searchRequestBuilder = client.prepareSearch(INDEX_NAME).setSearchType(SearchType.QUERY_THEN_FETCH) searchRequestBuilder.setPostFilter(filterBuilder) - return executeSearch(searchRequestBuilder, params) + return executeSearch(searchRequestBuilder, offset, max, sortBy, sortOrder) } - private static QueryResults executeSearch(SearchRequestBuilder searchRequestBuilder, GrailsParameterMap params) { - - if (params?.offset) { - searchRequestBuilder.setFrom(params.int("offset")) + private static V executeGenericSearch(SearchRequestBuilder searchRequestBuilder, Integer offset = null, Integer max = null, String sortBy = null, SortOrder sortOrder = null, Closure closure) { + if (offset) { + searchRequestBuilder.setFrom(offset) } - if (params?.max) { - searchRequestBuilder.setSize(params.int("max")) + if (max) { + searchRequestBuilder.setSize(max) } - if (params?.sort) { - def order = params?.order == "asc" ? SortOrder.ASC : SortOrder.DESC - searchRequestBuilder.addSort(params.sort as String, order) + if (sortBy) { + def order = sortOrder == SortOrder.ASC ? SortOrder.ASC : SortOrder.DESC + searchRequestBuilder.addSort(sortBy, order) } - def ct = new CodeTimer("Index search") + // TODO Create a Yammer metrics meter + //def ct = new CodeTimer("Index search") SearchResponse searchResponse = searchRequestBuilder.execute().actionGet(); - ct.stop(true) + //ct.stop(true) + + closure(searchResponse) + } - ct = new CodeTimer("Object retrieval (${searchResponse.hits.hits.length} of ${searchResponse.hits.totalHits} hits)") - def taskList = [] - if (searchResponse.hits) { - searchResponse.hits.each { hit -> - taskList << Task.get(hit.id.toLong()) + private static QueryResults executeSearch(SearchRequestBuilder searchRequestBuilder, Integer offset, Integer max, String sortBy, SortOrder sortOrder) { + + executeGenericSearch(searchRequestBuilder, offset, max, sortBy, sortOrder) { SearchResponse searchResponse -> + def ct = new CodeTimer("Object retrieval (${searchResponse.hits.hits.length} of ${searchResponse.hits.totalHits} hits)") + def taskList = [] + if (searchResponse.hits) { + searchResponse.hits.each { hit -> + taskList << Task.get(hit.id.toLong()) + } } + ct.stop(true) + return new QueryResults(list: taskList, totalCount: searchResponse?.hits?.totalHits ?: 0) } - ct.stop(true) - - return new QueryResults(list: taskList, totalCount: searchResponse?.hits?.totalHits ?: 0) } def ping() { - logService.log("ElasticSearch Service is ${node ? '' : 'NOT' } alive.") + log.info("ElasticSearch Service is${node ? ' ' : ' NOT ' }alive.") } + + @Timed + @Transactional(readOnly = true) + def indexTasks(Set ids, Closure cb = null) { + if (ids) { + + final numBuckets = (int)(ids.size() / Runtime.runtime.availableProcessors()) + 1 + + def promises = ids.toList() + .collate(numBuckets) + .findAll { !it.empty } + .collect { bucket -> + Task.async.task { + withStatelessSession { HibernateSession session -> + //session.setSessionProperty('defaultReadOnly', true) + withCriteria { + 'in'('id', bucket) + fetchMode 'project', FetchMode.JOIN + fetchMode 'project.labels', FetchMode.JOIN + fetchMode 'project.institution', FetchMode.JOIN + }.collect { task -> + IndexResponse r = null + try { + r = indexTask(task) + if (cb) cb.call(task.id) + } catch (e) { + log.error("exception trying to index task $task.id", e) + } + r + } + } + } + } + Promises.waitAll(promises).flatten() + } else { [] } + } + } /** @@ -254,10 +463,4 @@ public class QueryResults { public List list = [] public int totalCount = 0 -} - -public class IndexTaskTask { - - public long taskId - } \ No newline at end of file diff --git a/grails-app/services/au/org/ala/volunteer/LocalityService.groovy b/grails-app/services/au/org/ala/volunteer/LocalityService.groovy index 74d1fcf17..5642e52c4 100644 --- a/grails-app/services/au/org/ala/volunteer/LocalityService.groovy +++ b/grails-app/services/au/org/ala/volunteer/LocalityService.groovy @@ -42,7 +42,7 @@ class LocalityService { def rowsProcessed = 0; def rowsDeleted = Locality.executeUpdate("delete Locality where institutionCode = '${institutionCode}'") - logService.log "${rowsDeleted} rows deleted from Locality table" + log.info "${rowsDeleted} rows deleted from Locality table" Map col =[:] @@ -96,7 +96,7 @@ class LocalityService { sessionFactory.currentSession.flush() sessionFactory.currentSession.clear() propertyInstanceMap.get().clear() - logService.log "${rowsProcessed} rows processed, ${count} rows imported..." + log.info "${rowsProcessed} rows processed, ${count} rows imported..." } } @@ -180,7 +180,7 @@ class LocalityService { sql.eachRow("SELECT DISTINCT(LOWER(State)) from locality") { row -> def state = row[0] as String if (state) { - logService.log("Adding state to cache: ${state}") + log.info("Adding state to cache: ${state}") _allStates.add(state) } } @@ -203,7 +203,7 @@ class LocalityService { // need to strip out the state from the query... query = query.replaceAll(state, "").trim() - logService.log("Found state: ${state}, modified query is now ${query}") + log.info("Found state: ${state}, modified query is now ${query}") def wildcardChar = (wildcard ? "%" : "") def c = Locality.createCriteria(); @@ -239,7 +239,7 @@ class LocalityService { // need to strip out the state from the query... query = query.replaceAll(state, "").trim() - logService.log("Found state: ${state}, modified query is now ${query}") + log.info("Found state: ${state}, modified query is now ${query}") def wildcardChar = (wildcard ? "%" : "") def c = Locality.createCriteria(); diff --git a/grails-app/services/au/org/ala/volunteer/MultimediaService.groovy b/grails-app/services/au/org/ala/volunteer/MultimediaService.groovy index c4fd8fa59..deac5ee46 100644 --- a/grails-app/services/au/org/ala/volunteer/MultimediaService.groovy +++ b/grails-app/services/au/org/ala/volunteer/MultimediaService.groovy @@ -12,10 +12,10 @@ class MultimediaService { def deleteMultimedia(Multimedia media) { def dir = new File(grailsApplication.config.images.home + '/' + media.task?.projectId + '/' + media.task?.id + "/" + media.id) if (dir.exists()) { - logService.log("DeleteMultimedia: Preparing to remove multimedia directory ${dir.absolutePath}") + log.info("DeleteMultimedia: Preparing to remove multimedia directory ${dir.absolutePath}") FileUtils.deleteDirectory(dir) } else { - logService.log("DeleteMultimedia: Directory ${dir.absolutePath} does not exist!") + log.info("DeleteMultimedia: Directory ${dir.absolutePath} does not exist!") } } diff --git a/grails-app/services/au/org/ala/volunteer/PicklistService.groovy b/grails-app/services/au/org/ala/volunteer/PicklistService.groovy index 59f469645..3ceaf7a8f 100644 --- a/grails-app/services/au/org/ala/volunteer/PicklistService.groovy +++ b/grails-app/services/au/org/ala/volunteer/PicklistService.groovy @@ -37,13 +37,13 @@ class PicklistService { def picklist = Picklist.get(picklistId) // First delete the existing items... if (picklist) { - logService.log "Deleting existing items..." + log.info "Deleting existing items..." int itemsDeleted = 0; PicklistItem.findAllByPicklistAndInstitutionCode(picklist, institutionCode ?: null).each { it.delete(); itemsDeleted++; } - logService.log "${itemsDeleted} existing items deleted from picklist '${picklist.name}' and institutionCode '${institutionCode}'" + log.info "${itemsDeleted} existing items deleted from picklist '${picklist.name}' and institutionCode '${institutionCode}'" } def pattern = ~/^(['"])(.*)(\1)$/ @@ -68,7 +68,7 @@ class PicklistService { sessionFactory.currentSession.flush() sessionFactory.currentSession.clear() propertyInstanceMap.get().clear() - logService.log "${rowsProcessed} picklist items imported (${picklist.name})" + log.info "${rowsProcessed} picklist items imported (${picklist.name})" } } } finally { diff --git a/grails-app/services/au/org/ala/volunteer/ProjectService.groovy b/grails-app/services/au/org/ala/volunteer/ProjectService.groovy index 96ed72f6c..0fc530239 100644 --- a/grails-app/services/au/org/ala/volunteer/ProjectService.groovy +++ b/grails-app/services/au/org/ala/volunteer/ProjectService.groovy @@ -28,13 +28,13 @@ class ProjectService { try { multimediaService.deleteMultimedia(image) } catch (IOException ex) { - logService.log("Failed to delete multimedia: " + ex.message) + log.error("Failed to delete multimedia: ", e) } } } t.delete() } catch (Exception ex) { - logService.log("Failed to delete task ${t.id}: " + ex.message) + log.error("Failed to delete task ${t.id}: ", e) } } } @@ -50,7 +50,7 @@ class ProjectService { // First need to delete the staging profile, if it exists, and to do that you need to delete all its items first def profile = ProjectStagingProfile.findByProject(projectInstance) - logService.log("Delete Project ${projectInstance.id}: Delete staging profile...") + log.info("Delete Project ${projectInstance.id}: Delete staging profile...") if (profile) { StagingFieldDefinition.executeUpdate("delete from StagingFieldDefinition f where f.id in (select ff.id from StagingFieldDefinition ff where ff.profile = :profile)", [profile: profile]) profile.delete(flush: true, failOnError: true) @@ -64,59 +64,59 @@ class ProjectService { def topicCount = 0 // Also need to delete forum topics/posts that might be associated with this project - logService.log("Delete Project ${projectInstance.id}: Delete Task Forum Topics...") + log.info("Delete Project ${projectInstance.id}: Delete Task Forum Topics...") taskTopics?.each { topic -> - logService.log("Deleting topic ${topic.id}...") + log.info("Deleting topic ${topic.id}...") forumService.deleteTopic(topic) topicCount++ } - logService.log("Delete Project ${projectInstance.id}: Delete Project Forum Topics...") + log.info("Delete Project ${projectInstance.id}: Delete Project Forum Topics...") topics?.each { topic -> forumService.deleteTopic(topic) topicCount++ } - logService.log("Delete Project ${projectInstance.id}: ${topicCount} forum topics deleted") + log.info("Delete Project ${projectInstance.id}: ${topicCount} forum topics deleted") - logService.log("Project ${projectInstance.id}: Delete Project Forum Watchlist...") + log.info("Project ${projectInstance.id}: Delete Project Forum Watchlist...") forumService.deleteProjectForumWatchlist(projectInstance) //def projectForumWatchListCount = ProjectForumWatchList.executeUpdate("delete from ProjectForumWatchList where project = :project", [project: projectInstance]) - logService.log("Delete Project ${projectInstance.id}: project forum watch list deleted") + log.info("Delete Project ${projectInstance.id}: project forum watch list deleted") // Delete Multimedia - logService.log("Delete Project ${projectInstance.id}: Delete multimedia...") + log.info("Delete Project ${projectInstance.id}: Delete multimedia...") def mmCount = Multimedia.executeUpdate("delete from Multimedia m where m.id in (select mm.id from Multimedia mm where mm.task.project = :project)", [project: projectInstance]) - logService.log("Delete Project ${projectInstance.id}: ${mmCount} multimedia items deleted") + log.info("Delete Project ${projectInstance.id}: ${mmCount} multimedia items deleted") // Delete Fields - logService.log("Project ${projectInstance.id}: Delete Fields...") + log.info("Project ${projectInstance.id}: Delete Fields...") def fieldCount = Field.executeUpdate("delete from Field f where f.id in (select ff.id from Field ff where ff.task.project = :project)", [project: projectInstance]) - logService.log("Delete Project ${projectInstance.id}: ${fieldCount} fields deleted") + log.info("Delete Project ${projectInstance.id}: ${fieldCount} fields deleted") // Viewed Tasks - logService.log("Project ${projectInstance.id}: Delete Viewed Tasks...") + log.info("Project ${projectInstance.id}: Delete Viewed Tasks...") def viewedTaskCount = ViewedTask.executeUpdate("delete from ViewedTask vt where vt.id in (select vt2.id from ViewedTask vt2 where vt2.task.project = :project)", [project: projectInstance]) - logService.log("Delete Project ${projectInstance.id}: ${viewedTaskCount} viewed tasks deleted") + log.info("Delete Project ${projectInstance.id}: ${viewedTaskCount} viewed tasks deleted") // Viewed Tasks - logService.log("Project ${projectInstance.id}: Delete Task comments...") + log.info("Project ${projectInstance.id}: Delete Task comments...") def commentCount = TaskComment.executeUpdate("delete from TaskComment tc where tc.id in (select tc2.id from TaskComment tc2 where tc2.task.project = :project)", [project: projectInstance]) - logService.log("Delete Project ${projectInstance.id}: ${commentCount} task comments deleted") + log.info("Delete Project ${projectInstance.id}: ${commentCount} task comments deleted") // Delete Tasks // Tasks are deleted automatically because they're owned by the project // now we can delete the project itself - logService.log("Project ${projectInstance.id}: Delete Project...") + log.info("Project ${projectInstance.id}: Delete Project...") projectInstance.delete(flush: true, failOnError: true) // if we get here we can delete the project directory on the disk - logService.log("Project ${projectInstance.id}: Removing folder from disk...") + log.info("Project ${projectInstance.id}: Removing folder from disk...") def dir = new File(grailsApplication.config.images.home + '/' + projectInstance.id ) if (dir.exists()) { - logService.log("DeleteProject: Preparing to remove project directory ${dir.absolutePath}") + log.info("DeleteProject: Preparing to remove project directory ${dir.absolutePath}") FileUtils.deleteDirectory(dir) } else { - logService.log("DeleteProject: Directory ${dir.absolutePath} does not exist!") + log.warn("DeleteProject: Directory ${dir.absolutePath} does not exist!") } } @@ -340,20 +340,19 @@ class ProjectService { // Now check image size... def image = ImageIO.read(file) - logService.log("Checking Featured image for project ${projectInstance.id}: Dimensions ${image.width} x ${image.height}") + log.info("Checking Featured image for project ${projectInstance.id}: Dimensions ${image.width} x ${image.height}") if (image.width != 254 || image.height != 158) { - logService.log "Image is not the correct size. Scaling to 254 x 158..." + log.info "Image is not the correct size. Scaling to 254 x 158..." image = ImageUtils.scale(image, 254, 158) - logService.log "Saving new dimensions ${image.width} x ${image.height}" + log.info "Saving new dimensions ${image.width} x ${image.height}" ImageIO.write(image, "jpg", file) - logService.log "Done." + log.info "Done." } else { - logService.log "Image Ok. No scaling required." + log.info "Image Ok. No scaling required." } return true } catch (Exception ex) { - println ex - ex.printStackTrace() + log.error("Could not check and resize expedition image for $projectInstance", ex) return false } } diff --git a/grails-app/services/au/org/ala/volunteer/ProjectStagingService.groovy b/grails-app/services/au/org/ala/volunteer/ProjectStagingService.groovy index 8fa9984b3..a5aaf4b4a 100644 --- a/grails-app/services/au/org/ala/volunteer/ProjectStagingService.groovy +++ b/grails-app/services/au/org/ala/volunteer/ProjectStagingService.groovy @@ -27,6 +27,13 @@ class ProjectStagingService { project.featuredImageCopyright = projectDescriptor.imageCopyright project.inactive = true + if (projectDescriptor.labelIds) { + Label.findAllByIdInList(projectDescriptor.labelIds).each { project.addToLabels(it) } + } + if (projectDescriptor.picklistId) { + project.picklistInstitutionCode = projectDescriptor.picklistId + } + project.save(failOnError: true, flush: true) // Now we have a project id we can copy over the file system artifacts diff --git a/grails-app/services/au/org/ala/volunteer/TaskLoadService.groovy b/grails-app/services/au/org/ala/volunteer/TaskLoadService.groovy index aace55ad5..e994b076f 100644 --- a/grails-app/services/au/org/ala/volunteer/TaskLoadService.groovy +++ b/grails-app/services/au/org/ala/volunteer/TaskLoadService.groovy @@ -1,5 +1,6 @@ package au.org.ala.volunteer +import com.google.common.collect.Lists import groovy.time.TimeCategory import groovy.time.TimeDuration import org.apache.commons.io.FileUtils @@ -56,6 +57,14 @@ class TaskLoadService { } + /** + * Returns a defensive copy of the current queue + * @return + */ + List currentQueue() { + Lists.newArrayList(_loadQueue.iterator()) + } + def loadTaskFromCSV(Project project, String csv, boolean replaceDuplicates) { if (_loadQueue.size() > 0) { @@ -64,14 +73,14 @@ class TaskLoadService { Closure importClosure = default_csv_import - logService.log "Looking for import function for template: ${project.template.name}" + log.info "Looking for import function for template: ${project.template.name}" MetaProperty importClosureProperty = this.metaClass.properties.find() { it.name == "import_" + project.template.name } if (importClosureProperty) { - logService.log("Using 'import_${project.template.name} for import") + log.info("Using 'import_${project.template.name} for import") importClosure = importClosureProperty.getProperty(this) as Closure } else { - logService.log "Using default CSV import routine" + log.info "Using default CSV import routine" } try { @@ -84,7 +93,7 @@ class TaskLoadService { _loadQueue.put(taskDesc) } } else { - logService.log 'Skipping empty line' + log.info 'Skipping empty line' } } } catch (Exception ex) { @@ -130,7 +139,7 @@ class TaskLoadService { try { // Add shadow file contents... imgData.shadowFiles?.each { shadowFile -> - logService.log("Processing shadow files post task import ${task.id}: ${shadowFile.stagedFile.file}") + log.info("Processing shadow files post task import ${task.id}: ${shadowFile.stagedFile.file}") def file = new File(shadowFile.stagedFile.file as String) if (file && file.exists()) { def fieldValue = FileUtils.readFileToString(file) @@ -338,7 +347,7 @@ class TaskLoadService { try { md.afterDownload(t, multimedia, filePath) } catch (Exception ex) { - logService.log "Error calling after media download hook: ${ex.message}" + log.info "Error calling after media download hook: ${ex.message}" } } } @@ -434,6 +443,12 @@ class TaskLoadService { _cancel = true; } + List clearQueue() { + def tasks = [] + _loadQueue.drainTo(tasks) + tasks + } + def List getLastReport() { synchronized (_report) { return new ArrayList(_report) diff --git a/grails-app/services/au/org/ala/volunteer/TaskService.groovy b/grails-app/services/au/org/ala/volunteer/TaskService.groovy index 2e99138f7..06acda140 100644 --- a/grails-app/services/au/org/ala/volunteer/TaskService.groovy +++ b/grails-app/services/au/org/ala/volunteer/TaskService.groovy @@ -250,7 +250,7 @@ class TaskService { if (tasks) { def task = tasks.get(0) - println "getNextTask(project ${project.id}) found a task with no views: ${task.id}" + log.info("getNextTask(project ${project.id}) found a task with no views: ${task.id}") return task } @@ -268,7 +268,7 @@ class TaskService { if (tasks) { def task = tasks.get(0) - println "getNextTask(project ${project.id}) found a task: ${task.id}" + log.info("getNextTask(project ${project.id}) found a task: ${task.id}") return task } @@ -285,7 +285,7 @@ class TaskService { if (tasks) { def task = tasks.get(0) - println "getNextTask(project ${project.id}) found a task: ${task.id}" + log.info("getNextTask(project ${project.id}) found a task: ${task.id}") return task } @@ -520,7 +520,7 @@ class TaskService { try { def dir = new File(grailsApplication.config.images.home + '/' + projectId + '/' + taskId + "/" + multimediaId) if (!dir.exists()) { - logService.log "Creating dir ${dir.absolutePath}" + log.info "Creating dir ${dir.absolutePath}" dir.mkdirs() } fileMap.dir = dir.absolutePath @@ -533,7 +533,7 @@ class TaskService { return fileMap //file.close() } catch (Exception e) { - logService.log "Failed to load URL: ${imageUrl} : ${e}" + log.error("Failed to load URL: ${imageUrl}", e) } } @@ -673,7 +673,7 @@ class TaskService { try { image = ImageIO.read(file) } catch (Exception ex) { - logService.log("Exception trying to read image path: ${file.getAbsolutePath()} - ${ex}") + log.error("Exception trying to read image path: ${file.getAbsolutePath()}", ex) } if (image) { @@ -685,7 +685,7 @@ class TaskService { } return new ImageMetaData(width: width, height: height, url: imageUrl) } else { - logService.log("Could not read image file: ${file?.getAbsolutePath()} - could not get image metadata") + log.info("Could not read image file: ${file?.getAbsolutePath()} - could not get image metadata") } } diff --git a/grails-app/services/au/org/ala/volunteer/UserService.groovy b/grails-app/services/au/org/ala/volunteer/UserService.groovy index 389a4c0fb..b5e91d7d8 100644 --- a/grails-app/services/au/org/ala/volunteer/UserService.groovy +++ b/grails-app/services/au/org/ala/volunteer/UserService.groovy @@ -27,10 +27,10 @@ class UserService { def registerCurrentUser() { def userId = currentUserId def displayName = authService.displayName - logService.log("Checking user is registered: ${displayName} (UserId=${userId})") + log.info("Checking user is registered: ${displayName} (UserId=${userId})") if (userId) { if (User.findByUserId(userId) == null) { - logService.log("Registering new user: ${displayName} (UserId=${userId})") + log.info("Registering new user: ${displayName} (UserId=${userId})") User user = new User() user.userId = userId user.email = currentUserEmail @@ -57,15 +57,15 @@ class UserService { def getUserCounts(List ineligibleUsers = []) { def args = ineligibleUsers ? [ineligibleUsers: ineligibleUsers] : [:] def users = User.executeQuery(""" - select displayName, (transcribedCount + validatedCount) as score, id, userId + select new map(displayName as displayName, transcribedCount as transcribed, validatedCount as validated, (transcribedCount + validatedCount) as total, userId as userId, id as id) from User where (transcribedCount + validatedCount) > 0 ${ ineligibleUsers ? 'and userId not in (:ineligibleUsers)' : ''} order by (transcribedCount + validatedCount) desc """, args) - def deets = authService.getUserDetailsById(users.collect { it[3] }) + def deets = authService.getUserDetailsById(users.collect { it['userId'] }) if (deets) { - users.each { it[0] = deets.users.get(it[3]).displayName } + users.each { it['displayName'] = deets.users.get(it['userId']).displayName } } return users; } @@ -325,7 +325,7 @@ class UserService { userActivity.delete(flush: true) } if (purgeCount) { - logService.log("${purgeCount} activity records purged from database") + log.info("${purgeCount} activity records purged from database") } } diff --git a/grails-app/taglib/au/org/ala/volunteer/SortableTagLib.groovy b/grails-app/taglib/au/org/ala/volunteer/SortableTagLib.groovy new file mode 100644 index 000000000..938b96996 --- /dev/null +++ b/grails-app/taglib/au/org/ala/volunteer/SortableTagLib.groovy @@ -0,0 +1,119 @@ +package au.org.ala.volunteer + +import org.springframework.web.servlet.support.RequestContextUtils + +class SortableTagLib { + + static namespace = 's' + + static defaultEncodeAs = [taglib: 'raw'] + //static encodeAsForTags = [tagName: [taglib:'html'], otherTagName: [taglib:'none']] + + /** + * Renders a sortable column to support sorting in list views.
+ * + * Attribute title or titleKey is required. When both attributes are specified then titleKey takes precedence, + * resulting in the title caption to be resolved against the message source. In case when the message could + * not be resolved, the title will be used as title caption.
+ * + * Examples:
+ * + * <g:sortableColumn property="title" title="Title" />
+ * <g:sortableColumn property="title" title="Title" style="width: 200px" />
+ * <g:sortableColumn property="title" titleKey="book.title" />
+ * <g:sortableColumn property="releaseDate" defaultOrder="desc" title="Release Date" />
+ * <g:sortableColumn property="releaseDate" defaultOrder="desc" title="Release Date" titleKey="book.releaseDate" />
+ * + * @emptyTag + * + * @attr tag - the type of tag + * @attr property - name of the property relating to the field + * @attr defaultOrder default order for the property; choose between asc (default if not provided) and desc + * @attr title title caption for the column + * @attr titleKey title key to use for the column, resolved against the message source + * @attr params a map containing request parameters + * @attr action the name of the action to use in the link, if not specified the list action will be linked + * @attr params A map containing URL query parameters + * @attr class CSS class name + */ + Closure sortableColumn = { attrs -> + def writer = out + if (!attrs.property) { + throwTagError("Tag [sortableColumn] is missing required attribute [property]") + } + + if (!attrs.title && !attrs.titleKey) { + throwTagError("Tag [sortableColumn] is missing required attribute [title] or [titleKey]") + } + + def property = attrs.remove("property") + def action = attrs.action ? attrs.remove("action") : (actionName ?: "list") + + def defaultOrder = attrs.remove("defaultOrder") + if (defaultOrder != "desc") defaultOrder = "asc" + + def tag = attrs.remove("tag") + if (!tag) { + throwTagError("Tag [sortableColumn] is missing required attribute [tag]") + } + + // current sorting property and order + def sort = params.sort + def order = params.order + + // add sorting property and params to link params + def linkParams = [:] + if (params.id) linkParams.put("id", params.id) + def paramsAttr = attrs.remove("params") + if (paramsAttr) linkParams.putAll(paramsAttr) + linkParams.sort = property + + // propagate "max" and "offset" standard params + if (params.max) linkParams.max = params.max + if (params.offset) linkParams.offset = params.offset + + // determine and add sorting order for this column to link params + attrs.class = (attrs.class ? "${attrs.class} sortable" : "sortable") + if (property == sort) { + attrs.class = attrs.class + " sorted " + order + if (order == "asc") { + linkParams.order = "desc" + } + else { + linkParams.order = "asc" + } + } + else { + linkParams.order = defaultOrder + } + + // determine column title + def title = attrs.remove("title") + def titleKey = attrs.remove("titleKey") + def mapping = attrs.remove('mapping') + if (titleKey) { + if (!title) title = titleKey + def messageSource = grailsAttributes.messageSource + def locale = RequestContextUtils.getLocale(request) + title = messageSource.getMessage(titleKey, null, title, locale) + } + + writer << "<$tag " + // process remaining attributes + attrs.each { k, v -> + writer << "${k}=\"${v?.encodeAsHTML()}\" " + } + writer << '>' + def linkAttrs = [params: linkParams] + if (mapping) { + linkAttrs.mapping = mapping + } + + linkAttrs.action = action + + writer << link(linkAttrs) { + title + } + writer << "" + } +} diff --git a/grails-app/taglib/au/org/ala/volunteer/VolunteerTagLib.groovy b/grails-app/taglib/au/org/ala/volunteer/VolunteerTagLib.groovy index 154e86632..53157572e 100644 --- a/grails-app/taglib/au/org/ala/volunteer/VolunteerTagLib.groovy +++ b/grails-app/taglib/au/org/ala/volunteer/VolunteerTagLib.groovy @@ -1,6 +1,7 @@ package au.org.ala.volunteer import grails.converters.JSON +import grails.util.Environment import groovy.time.TimeCategory import au.org.ala.cas.util.AuthenticationCookieUtils import groovy.xml.MarkupBuilder @@ -16,8 +17,9 @@ class VolunteerTagLib { def markdownService def institutionService def authService + def achievementService - static returnObjectForTags = ['emailForUserId', 'displayNameForUserId'] + static returnObjectForTags = ['emailForUserId', 'displayNameForUserId', 'achievementBadgeBase', 'newAchievements', 'achievementsEnabled'] def isLoggedIn = { attrs, body -> @@ -160,11 +162,11 @@ class VolunteerTagLib { items << [forum:[link: createLink(controller: 'forum'), title: 'Forum']] } - def dashboardEnabled = settingsService.getSetting(SettingDefinition.EnableMyDashboard) + def dashboardEnabled = settingsService.getSetting(SettingDefinition.EnableMyNotebook) if (dashboardEnabled) { def isLoggedIn = AuthenticationCookieUtils.cookieExists(request, AuthenticationCookieUtils.ALA_AUTH_COOKIE) if (isLoggedIn || userService.currentUser) { - items << [userDashboard: [link: createLink(controller:'user', action:'dashboard'), title:"My Dashboard"]] + items << [userDashboard: [link: createLink(controller:'user', action:'notebook'), title:"My Notebook"]] } } @@ -559,6 +561,35 @@ class VolunteerTagLib { } } + /** + * + */ + def achievementBadgeBase = { attrs, body -> + achievementService.badgeImageUrlPrefix + } + + /** + * @achievement The AchievementDescription + * @id The id of the institution + */ + def achievementBadgeUrl = { attrs, body -> + def achievementDesc = attrs.achievement ?: AchievementDescription.get(attrs.id as Long) + out << achievementService.getBadgeImageUrl(achievementDesc) + } + + /** + * @attr achievementDescription + */ + def ifAchievementHasBadge = { attrs, body -> + def achievementDescription = attrs.achievementDescription as AchievementDescription + if (!achievementDescription) { + def id = (attrs.achievementDescription ?: attrs.id) as Long + achievementDescription = AchievementDescription.get(id) + } + if (achievementService.hasBadgeImage(achievementDescription)) { + out << body() + } + } /** * @attr email @@ -654,4 +685,41 @@ class VolunteerTagLib { } } + + /** + * Output the meta tags (HTML head section) for the build meta data in application.properties + * E.g. + * + * etc. + * + * Updated to use properties provided by build-info plugin + */ + def addApplicationMetaTags = { attrs -> + def metaList = ['app.version', 'app.grails.version', 'build.date', 'scm.version', 'environment.TRAVIS_JDK_VERSION', 'environment.TRAVIS_REPO_SLUG', 'environment.TRAVIS_BUILD_NUMBER', 'environment.TRAVIS_TAG', 'environment.TRAVIS_BRANCH', 'environment.TRAVIS_COMMIT'] + def mb = new MarkupBuilder(out) + + mb.meta(name:'grails.env', content: "${Environment.current}") + metaList.each { + mb.meta(name:it, content: g.meta(name:it)) + } + mb.meta(name:'java.version', content: "${System.getProperty('java.version')}") + } + + /** + * Gets the list of new achievements for the current user + */ + def newAchievements = { attrs -> + if (settingsService.getSetting(SettingDefinition.EnableMyNotebook) && settingsService.getSetting(SettingDefinition.EnableAchievementCalculations)) { + achievementService.newAchievementsForUser(userService.currentUser) + } else { + [] + } + } + + /** + * Returns true if achievements are enabled, false otherwise + */ + def achievementsEnabled = { attrs -> + settingsService.getSetting(SettingDefinition.EnableMyNotebook) && settingsService.getSetting(SettingDefinition.EnableAchievementCalculations) + } } \ No newline at end of file diff --git a/grails-app/views/achievementDescription/_form.gsp b/grails-app/views/achievementDescription/_form.gsp new file mode 100644 index 000000000..2772ac75a --- /dev/null +++ b/grails-app/views/achievementDescription/_form.gsp @@ -0,0 +1,254 @@ +<%@ page import="au.org.ala.volunteer.AggregationType; au.org.ala.volunteer.AchievementType; au.org.ala.volunteer.AchievementDescription" %> + + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +%{--
--}% + %{----}% + %{--
--}% + %{----}% + %{--
--}% +%{--
--}% + +
+ +
+ +
+
+ +
+ +
+ + " width="140" height="140" /> + + +
+
+ + + +<%--div class="fieldcontain ${hasErrors(bean: achievementDescriptionInstance, field: 'badge', 'error')} required"> + + + + + + +jQuery(function($) { + var id = "${achievementDescriptionInstance?.id ?: 0}" + var badgeBase = "${cl.achievementBadgeBase()}"; + var noBadgeUrl = ""; + + function toggleTypeFields() { + var type = $('#type').val(); + switch (type) { + case "ELASTIC_SEARCH_QUERY": + toggleEsFields(true); + toggleGroovyFields(false); + toggleEsAgFields(false); + break; + case "GROOVY_SCRIPT": + toggleEsFields(false); + toggleGroovyFields(true); + toggleEsAgFields(false); + break; + case "ELASTIC_SEARCH_AGGREGATION_QUERY": + toggleEsFields(true); + toggleEsAgFields(true); + toggleGroovyFields(true); + break; + } + } + + function toggleEsFields(on) { toggleFields('.esType', on) } + function toggleEsAgFields(on) { toggleFields('.agType', on) } + function toggleGroovyFields(on) { toggleFields('.grType', on) } + + function toggleFields(selector, on) { + $(selector).toggleClass('required', on).toggleClass('hidden', !on); + $(selector + ' span.required-indicator').toggleClass('hidden', !on); + //$(selector + ' input, ' + selector + ' textarea').prop('required', on); + //else $(selector + ' input').removeProp('required'); + } + + var searchEditor = CodeMirror.fromTextArea(document.getElementById("searchQuery"), { + matchBrackets: true, + autoCloseBrackets: true, + mode: "application/json", + lineWrapping: true, + theme: 'monokai' + }); + var aggEditor = CodeMirror.fromTextArea(document.getElementById("aggregationQuery"), { + matchBrackets: true, + autoCloseBrackets: true, + mode: "application/json", + lineWrapping: true, + theme: 'monokai' + }); + var codeEditor = CodeMirror.fromTextArea(document.getElementById("code"), { + matchBrackets: true, + autoCloseBrackets: true, + mode: "text/x-groovy", + lineWrapping: true, + theme: 'monokai' + }); + + function updateTextArea(editor, event) { + var ta = editor.getTextArea(); + var val = editor.getValue(); + ta.value = val; + //editor.getTextArea().value = editor.getValue() + } + + searchEditor.on('change', updateTextArea); + aggEditor.on('change', updateTextArea); + codeEditor.on('change', updateTextArea); + + toggleTypeFields(); + $('#type').change(function(e) { toggleTypeFields(); }); + + function upload(event) { + event.preventDefault(); + // Get the selected files from the input. + var files = $('#file-select').prop('files'); + // Create a new FormData object. + var formData = new FormData(); + // Loop through each of the selected files. + if (files.length == 0) return; + + var file = files[0]; + + // Check the file type. + if (!file.type.match('image.*')) { + alert("File type " + file.type + "doesn't match image.*"); + return; + } + + if (id != 0) formData.append('id', id); + + // Add the file to the request. + formData.append('imagefile', file, file.name); + var r = $.ajax({ + type: 'POST', + headers: { + Accept : "application/json" + }, + url: '${createLink(controller: 'achievementDescription', action: 'uploadBadgeImage')}?format=json', + data: formData, + processData: false, + contentType: false, + xhr: uploadProgressXhrFactory + }); + + event.target.innerHTML = 'Uploading...'; + r.done(function( data, textStatus, jqXHR ) { + $('#upload-progress').addClass('hidden'); + $('#badge').val(data.filename).trigger('change'); + }); + + r.fail(function ( jqXHR, textStatus, errorThrown ) { + $('#upload-progress').addClass('hidden'); + alert("Upload failed :("); + console.log(errorThrown); + }); + } + + function uploadProgressXhrFactory() + { + var xhr = new window.XMLHttpRequest(); + //Upload progress + xhr.upload.addEventListener("progress", function(evt){ + if (evt.lengthComputable) { + var percentComplete = evt.loaded / evt.total; + $('#upload-progress').removeClass('hidden'); + $('#upload-progress bar').width(percentComplete*100+"%"); + } + }, false); + return xhr; + } + + $('#upload-button').click(upload); + + $('#badge').change(function (e) { + var i = $(e.target).val(); + var src = i ? badgeBase + i : noBadgeUrl; + $('#badge-image').attr('src',src); + }); + +}); + \ No newline at end of file diff --git a/grails-app/views/achievementDescription/awards.gsp b/grails-app/views/achievementDescription/awards.gsp new file mode 100644 index 000000000..777378372 --- /dev/null +++ b/grails-app/views/achievementDescription/awards.gsp @@ -0,0 +1,185 @@ +<%@ page import="au.org.ala.volunteer.AchievementDescription" %> + + + + + + <g:message code="default.edit.label" args="[entityName]" /> + + #ajax-spinner.disabled, .ajax-spinner.disabled { + display: none; + } + li.user > span { + margin-right: 5px; + } + i.icon-remove { + cursor: pointer; + } + + + + Awards + + + + + + + + + +
+ %{----}% + %{--
${flash.message}
--}% + %{--
--}% + + + + + + + + + + + + + + + + + + + + + +
UserAwardedNotifiedCurrently EligibleActions
${award.user.displayName}${award.awarded}${award.userNotified} + %{----}% + +
+
+ Grant achievement + +
+
+ +
+ + + +
+
+
+
+ +
+
+
+
+
+
+ +jQuery(function($) { + + var ids = ; + + var checkAwardUrl = "${createLink(controller: 'achievementDescription', action: 'checkAward', id: achievementDescriptionInstance.id)}"; + + var i, j, temparray, chunk = 20; + for (i=0,j=ids.length; i' + match + ''; + }) + ' (' + item.email.replace(new RegExp('(' + query + ')', 'ig'), function ($1, match) { return '' + query + ''; }) + ')'; + } + + function typeaheadSorter(items) { + return items; + } + + function typeaheadMatcher(item) { + return true; + } + + function typeaheadUpdate(item) { + var obj = JSON.parse(item); + $('#userId').val(obj.userId); + return obj.displayName; + } + + $('#user').typeahead({ + source: typeahead, + minLength: 2, + highlighter: typeaheadHighlighter, + matcher: typeaheadMatcher, + sorter: typeaheadSorter, + updater: typeaheadUpdate + }); +}); + + + diff --git a/grails-app/views/achievementDescription/create.gsp b/grails-app/views/achievementDescription/create.gsp new file mode 100644 index 000000000..e0a818f42 --- /dev/null +++ b/grails-app/views/achievementDescription/create.gsp @@ -0,0 +1,45 @@ + + + + + + <g:message code="default.create.label" args="[entityName]"/> + + + + + + + +
+

+ +
${flash.message}
+
+ + + + +
+ +
+
+ +
+
+
+ + diff --git a/grails-app/views/achievementDescription/edit.gsp b/grails-app/views/achievementDescription/edit.gsp new file mode 100644 index 000000000..1453a0a4d --- /dev/null +++ b/grails-app/views/achievementDescription/edit.gsp @@ -0,0 +1,63 @@ +<%@ page import="au.org.ala.volunteer.AchievementDescription" %> + + + + + + <g:message code="default.edit.label" args="[entityName]" /> + + + General Settings + + + +
+
+
+ %{----}% + %{--
${flash.message}
--}% + %{--
--}% + + + + + +
+ +
+
+
+
+ +
+
+
+
+
+ + $("[name='enabled']").bootstrapSwitch().on('switchChange.bootstrapSwitch', function(event, state) { + var p = $.ajax({ + type: 'POST', + headers: { + Accept : "application/json" + }, + url: '${createLink(controller: 'achievementDescription', action: 'enable', id: achievementDescriptionInstance?.id)}?format=json', + data: { + enabled: state + }, + dataType: 'json' + }); + + p.fail(function ( jqXHR, textStatus, errorThrown ) { + alert("Could not enable badge :( Please refresh and try again."); + $(event.target).bootstrapSwitch('state', !state, true); + console.log(errorThrown); + }); + }); + + + diff --git a/grails-app/views/achievementDescription/editTest.gsp b/grails-app/views/achievementDescription/editTest.gsp new file mode 100644 index 000000000..c6ea24a34 --- /dev/null +++ b/grails-app/views/achievementDescription/editTest.gsp @@ -0,0 +1,138 @@ +<%@ page import="au.org.ala.volunteer.AchievementDescription" %> + + + + + + <g:message code="default.edit.label" args="[entityName]" /> + + #ajax-spinner.disabled { + display: none; + } + li.user > span { + margin-right: 5px; + } + i.icon-remove { + cursor: pointer; + } + + + + Tester + + + %{--
--}% +
+
+ %{----}% + %{--
${flash.message}
--}% + %{--
--}% + + + + + + + + + + + + + + + +
UserAchieved?
+ ${cheev.key} + + ${cheev.value} +
+ +
+ Check User +
+
+ +
+ + +
+
+
+
+ +
+
+
+
+
+
+ +jQuery(function($) { + var url = "${createLink(controller: 'leaderBoardAdmin', action: 'findEligibleUsers')}"; + function showSpinner() { + $('#ajax-spinner').removeClass('disabled') + } + function hideSpinner() { + $('#ajax-spinner').addClass('disabled') + } + + function typeahead(query, process) { + showSpinner(); + $.getJSON(url, {term: query, filter: false}) + .done(function(data) { + var toString = function() { + return JSON.stringify(this); + }; + for (var i = 0; i < data.length; ++i) { + data[i].toString = toString; + } + process(data); + }) + .fail(function(e) { + ajaxFail(); + process([]); + }) + .always(hideSpinner); + } + + function ajaxFail() { + alert("Failure contacting server, please refresh and try again"); + } + + function typeaheadHighlighter(item) { + var query = this.query.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&'); + return item.displayName.replace(new RegExp('(' + query + ')', 'ig'), function ($1, match) { + return '' + match + ''; + }) + ' (' + item.email.replace(new RegExp('(' + query + ')', 'ig'), function ($1, match) { return '' + query + ''; }) + ')'; + } + + function typeaheadSorter(items) { + return items; + } + + function typeaheadMatcher(item) { + return true; + } + + function typeaheadUpdate(item) { + var obj = JSON.parse(item); + $('#userId').val(obj.userId); + return obj.displayName; + } + + $('#user').typeahead({ + source: typeahead, + minLength: 2, + highlighter: typeaheadHighlighter, + matcher: typeaheadMatcher, + sorter: typeaheadSorter, + updater: typeaheadUpdate + }); +}); + + + diff --git a/grails-app/views/achievementDescription/index.gsp b/grails-app/views/achievementDescription/index.gsp new file mode 100644 index 000000000..76963e07e --- /dev/null +++ b/grails-app/views/achievementDescription/index.gsp @@ -0,0 +1,68 @@ + +<%@ page import="au.org.ala.volunteer.AchievementDescription" %> + + + + + + <g:message code="default.list.label" args="[entityName]" /> + + + + <% + pageScope.crumbs = [ + [link:createLink(controller:'admin'),label:message(code:'default.admin.label', default:'Admin')] + ] + + %> + +  Add Badge + +
+

+ +
${flash.message}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

${fieldValue(bean: achievementDescriptionInstance, field: "name")}

+
+

${fieldValue(bean: achievementDescriptionInstance, field: "description")}

+
+
${fieldValue(bean: achievementDescriptionInstance, field: "enabled")}
+ +
+ + diff --git a/grails-app/views/achievementDescription/show.gsp b/grails-app/views/achievementDescription/show.gsp new file mode 100644 index 000000000..06a833068 --- /dev/null +++ b/grails-app/views/achievementDescription/show.gsp @@ -0,0 +1,87 @@ +<%@ page import="au.org.ala.volunteer.AchievementDescription" %> + + + + + + <g:message code="default.show.label" args="[entityName]"/> + + + + + + + +
+

+ +
${flash.message}
+
+
    + + +
  1. + + + + +
  2. +
    + + +
  3. + + + + +
  4. +
    + + +
  5. + + + + +
  6. +
    + + +
  7. + + + + +
  8. +
    + +
+ +
+ + +
+
+
+ + diff --git a/grails-app/views/admin/index.gsp b/grails-app/views/admin/index.gsp index 37d2e0fdb..fecb3b2d7 100644 --- a/grails-app/views/admin/index.gsp +++ b/grails-app/views/admin/index.gsp @@ -9,7 +9,7 @@ - Version ${grailsApplication.metadata['app.version']}.${grailsApplication.metadata['app.buildNumber']} (built ${grailsApplication.metadata['app.buildDate']} ${grailsApplication.metadata['app.buildProfile']}) + Version ${grailsApplication.metadata['app.version']} (built ${grailsApplication.metadata['app.buildDate']} ${grailsApplication.metadata['app.buildProfile']} sha: ${grailsApplication.metadata['environment.TRAVIS_COMMIT']}) @@ -67,7 +67,14 @@ Manage Institutions Manage Institutions - + + Manage Badges + Manage Achievements + + + Manage Tags + Manage Project Tags + Advanced Settings Advanced Settings diff --git a/grails-app/views/admin/stagingTasks.gsp b/grails-app/views/admin/stagingTasks.gsp new file mode 100644 index 000000000..43a1a8151 --- /dev/null +++ b/grails-app/views/admin/stagingTasks.gsp @@ -0,0 +1,100 @@ + + + + + <g:message code="admin.stagingQueue.label" default="Administration - Staging Tasks"/> + + + + + jQuery(function($) { + $('#button-bar').find('button.btn-danger').click(function(e) { + bootbox.confirm("Are you sure you want to " + e.target.dataset.message + "?", e.target.dataset.cancel, e.target.dataset.confirm, function(result) { + if (result) { + window.open(e.target.dataset.href, "_self"); + } + }) + }) + }) + + + + + + + + <% + pageScope.crumbs = [ + [link:createLink(controller:'admin'),label:message(code:'default.admin.label', default:'Admin')], + [link:createLink(controller:'admin', actions: 'tools'),label:message(code:'default.tools.label', default:'Tools')] + ] + %> + + +
+
+
+ + +
+
+
+
+
+

Staging Queue:

+ + + + + + + + + + + + + + + + +
+ ProjectExternal identifierImage URL
$taskDescriptor?.project?.name$taskDescriptor?.externalIdentifier$taskDescriptor?.imageUrl
+
+
+
Start Time
+
${status.startTime}
+
Total Tasks
+
${status.totalTasks}
+
Current Item
+
${status.currentItem}
+
Queue Length
+
${status.queueLength}
+
Tasks Loaded
+
${status.tasksLoaded}
+
Started By
+
${status.startedBy}
+
Time Remaining
+
${status.timeRemaining}
+
Error Count
+
${status.errorCount}
+
+
+
+
+ + diff --git a/grails-app/views/admin/tools.gsp b/grails-app/views/admin/tools.gsp index b3d7ab929..1214a88b1 100644 --- a/grails-app/views/admin/tools.gsp +++ b/grails-app/views/admin/tools.gsp @@ -3,12 +3,23 @@ - <g:message code="admin.label" default="Administration - Tools"/> + <g:message code="admin.tools.label" default="Administration - Tools"/> + + - $(document).ready(function() { + jQuery(function($) { + $('button.confirmation-required').click(function(e) { + var confirm = e.target.dataset.confirm || 'Confirm'; + var cancel = e.target.dataset.cancel || 'Cancel'; + bootbox.confirm("Are you sure you want to " + e.target.dataset.message, cancel, confirm, function(result) { + if (result) { + window.open(e.target.dataset.href, "_self"); + } + }) + }); }); @@ -28,6 +39,17 @@ + + + @@ -35,11 +57,66 @@

Full Text Index

- Reindex all tasks - Recreate index + +
Background queue length:
+ +
+ Raw Search Query +
+
+ + + + +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+
@@ -54,10 +131,40 @@ }); function updateQueueLength() { - $.ajax("${createLink(controller:'ajax', action:'getIndexerQueueLength')}").done(function(results) { + $.ajax("${createLink(controller:'ajax', action:'getUpdateQueueLength')}").done(function(results) { $("#queueLength").html(results.queueLength); }); } + var qEditor = CodeMirror.fromTextArea(document.getElementById("query"), { + matchBrackets: true, + autoCloseBrackets: true, + mode: "application/json", + lineWrapping: true, + theme: 'monokai' + }); + var aEditor = CodeMirror.fromTextArea(document.getElementById("aggregation"), { + matchBrackets: true, + autoCloseBrackets: true, + mode: "application/json", + lineWrapping: true, + theme: 'monokai' + }); + + + var queries = { + matchAll: { q: { "match_all": { } }, a: null }, + projectId: { q: { "constant_score": { "filter": { "term" : { "projectid" : 0 } } } } }, + projectName: { q: { "constant_score": { "filter": { "term" : { "project.name" : "" } } } } }, + taskId: { q: { "constant_score": { "filter": { "term" : { "id" : 0 } } } } } + } + + $('#set-query').click('button', function(e) { + e.preventDefault(); + var q = queries[e.target.dataset.query]; + qEditor.getDoc().setValue(JSON.stringify(q.q, null, 2)); + aEditor.getDoc().setValue(q.a ? JSON.stringify(q.a, null, 2) : ""); + }); + diff --git a/grails-app/views/buildInfo/index.gsp b/grails-app/views/buildInfo/index.gsp new file mode 100644 index 000000000..626d6ffe4 --- /dev/null +++ b/grails-app/views/buildInfo/index.gsp @@ -0,0 +1,40 @@ +%{-- Custom version of BuildInfo plugin index page (taken from 1.2.8) --}% +%{-- Added layout meta tag so that buildInfo page is skinned --}% +<%@ page contentType="text/html;charset=UTF-8" %> + + + + + Build Info + + + + +
+ + + + + +
+ + diff --git a/grails-app/views/getInvolved.gsp b/grails-app/views/getInvolved.gsp index c98f77022..fa15add62 100644 --- a/grails-app/views/getInvolved.gsp +++ b/grails-app/views/getInvolved.gsp @@ -50,7 +50,7 @@ When an expedition is finished, the data is returned to the institution, checked and processed, and if relevant to Australia is uploaded to the Atlas of Living Australia (ALA), where it can be used by the general public and the research community. Expedition data from non-Australian institutions are uploaded to sites like the Global Biodiversity Information Facility (which also receives the data from the ALA) from where it is available to scientists around the world.

- If you encounter any problems, you can visit the discussion forums or contact us by email. + If you encounter any problems, you can visit the discussion forums or contact us by email.

Thank you for joining our team. ALA online volunteers have transcribed tens of thousands of records to date, and have made a valuable contribution to many institutions’ datasets and the science that they underpin. diff --git a/grails-app/views/index.gsp b/grails-app/views/index.gsp index f318079a9..793ffd40a 100644 --- a/grails-app/views/index.gsp +++ b/grails-app/views/index.gsp @@ -185,7 +185,7 @@ - View my tasks + View my notebook diff --git a/grails-app/views/label/_form.gsp b/grails-app/views/label/_form.gsp new file mode 100644 index 000000000..2b49f8c07 --- /dev/null +++ b/grails-app/views/label/_form.gsp @@ -0,0 +1,22 @@ +<%@ page import="au.org.ala.volunteer.Label" %> + + + +

+ + + +
+ +
+ + + +
+ diff --git a/grails-app/views/label/create.gsp b/grails-app/views/label/create.gsp new file mode 100644 index 000000000..8a4e2d840 --- /dev/null +++ b/grails-app/views/label/create.gsp @@ -0,0 +1,38 @@ + + + + + + <g:message code="default.create.label" args="[entityName]" /> + + + + +
+

+ +
${flash.message}
+
+ + + + +
+ +
+
+ +
+
+
+ + diff --git a/grails-app/views/label/edit.gsp b/grails-app/views/label/edit.gsp new file mode 100644 index 000000000..8859d61fa --- /dev/null +++ b/grails-app/views/label/edit.gsp @@ -0,0 +1,41 @@ +<%@ page import="au.org.ala.volunteer.Label" %> + + + + + + <g:message code="default.edit.label" args="[entityName]" /> + + + + +
+

+ +
${flash.message}
+
+ + + + + +
+ +
+
+ +
+
+
+ + diff --git a/grails-app/views/label/index.gsp b/grails-app/views/label/index.gsp new file mode 100644 index 000000000..dfe7dd652 --- /dev/null +++ b/grails-app/views/label/index.gsp @@ -0,0 +1,81 @@ + +<%@ page import="org.springframework.validation.FieldError; au.org.ala.volunteer.Label" %> + + + + + + <g:message code="default.list.label" args="[entityName]" /> + + + + + + <% + pageScope.crumbs = [ + [link:createLink(controller:'admin'),label:message(code:'default.admin.label', default:'Admin')] + ] + %> + +
+
+
+ + + +
+
+ + +
 
+
+ + + +
+
+
+ + + +
+
+
+
+ + + +
+ +
+
+
+ + +
+
+
+
+
+
+ + diff --git a/grails-app/views/label/show.gsp b/grails-app/views/label/show.gsp new file mode 100644 index 000000000..8d3b1124b --- /dev/null +++ b/grails-app/views/label/show.gsp @@ -0,0 +1,53 @@ + +<%@ page import="au.org.ala.volunteer.Label" %> + + + + + + <g:message code="default.show.label" args="[entityName]" /> + + + + +
+

+ +
${flash.message}
+
+
    + + +
  1. + + + + +
  2. +
    + + +
  3. + + + + +
  4. +
    + +
+ +
+ + +
+
+
+ + diff --git a/grails-app/views/layouts/achievementSettingsLayout.gsp b/grails-app/views/layouts/achievementSettingsLayout.gsp new file mode 100644 index 000000000..c03cf61af --- /dev/null +++ b/grails-app/views/layouts/achievementSettingsLayout.gsp @@ -0,0 +1,74 @@ +%{-- + - Copyright (C) 2013 Atlas of Living Australia + - All Rights Reserved. + - + - The contents of this file are subject to the Mozilla Public + - License Version 1.1 (the "License"); you may not use this file + - except in compliance with the License. You may obtain a copy of + - the License at http://www.mozilla.org/MPL/ + - + - Software distributed under the License is distributed on an "AS + - IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or + - implied. See the License for the specific language governing + - rights and limitations under the License. + --}% + + + + + + <g:message code="default.edit.label" args="[entityName]" /> + + + + + + + <% + pageScope.crumbs = [ + [link: createLink(controller: 'admin', action: 'index'), label: 'Admin'], + [link: createLink(controller: 'achievementDescription', action: 'index'), label: 'Manage Achievements' ], + [link: createLink(controller: 'achievementDescription', action: 'edit', id:achievementDescriptionInstance?.id), label: achievementDescriptionInstance.name ] + ] + %> +

Achievement Settings - ${achievementDescriptionInstance?.name}

+
+ +
+ +
+
+ +
+ +
+ + ${achievementDescriptionInstance.name} - +
+ +
+ +
+
+
+ +
+
+
+
+
+ +
\ No newline at end of file diff --git a/grails-app/views/layouts/ala-bootstrap.gsp b/grails-app/views/layouts/ala-bootstrap.gsp index 230d78c96..674f99f2f 100644 --- a/grails-app/views/layouts/ala-bootstrap.gsp +++ b/grails-app/views/layouts/ala-bootstrap.gsp @@ -3,8 +3,7 @@ - - + @@ -16,18 +15,18 @@ - %{----}% - + + + - %{----}% @@ -118,6 +117,49 @@ + + + + + + + + + +jQuery(function($) { + var cheevs = ; + var acceptUrl = "${g.createLink(controller: 'ajax', action: 'acceptAchievements')}"; + $('#achievement-notifier').on('show', function () { + $.ajax(acceptUrl, { + type: 'post', + data: { ids : cheevs }, + dataType: 'json' + }); + }).modal('show'); +}); + + + --}% - %{----}% - %{----}% - %{----}% diff --git a/grails-app/views/leaderBoardAdmin/index.gsp b/grails-app/views/leaderBoardAdmin/index.gsp index 2a448ce32..0409c49d0 100644 --- a/grails-app/views/leaderBoardAdmin/index.gsp +++ b/grails-app/views/leaderBoardAdmin/index.gsp @@ -65,7 +65,7 @@ jQuery(function ($) { .done(function(data) { var toString = function() { return JSON.stringify(this); - } + }; for (var i = 0; i < data.length; ++i) { data[i].toString = toString; } diff --git a/grails-app/views/project/createNewProject/projectExtras.gsp b/grails-app/views/project/createNewProject/projectExtras.gsp new file mode 100644 index 000000000..7b45c7067 --- /dev/null +++ b/grails-app/views/project/createNewProject/projectExtras.gsp @@ -0,0 +1,131 @@ + + + + + Create a new Expedition - Extra Settings + + + + $(document).ready(function () { + bvp.bindTooltips(); + bvp.suppressEnterSubmit(); + + $("#btnNext").click(function (e) { + e.preventDefault(); + bvp.submitWithWebflowEvent($(this)); + }); + + }); + + jQuery(function($) { + var labelColourMap = ; + labelAutocomplete("#label", "${createLink(controller: 'project', action: 'newLabels')}", '', function(item) { + var obj = JSON.parse(item); + var labelsElem = $('#labels'); + $( "" ) + .addClass("label") + .addClass(labelColourMap[obj.category]) + .attr("title", obj.category) + .text(obj.value) + .append( + $( "" ) + .attr("data-label-id", obj.id) + .addClass("icon-remove") + .addClass("icon-white") + ) + .appendTo( + labelsElem + ); + + $("") + .attr("type", "hidden") + .attr("id", "hidden-label-" + obj.id) + .attr('name', "labelId[]") + .attr("value", obj.id) + .appendTo( + labelsElem + ); + return null; + }); + + function onDeleteClick(e) { + var id = $(e.target).data('labelId'); + var t = $(e.target); + var p = t.parent("span"); + p.remove(); + $('#labels').find('#hidden-label-' + id).remove(); + + } + + $('#labels').on('click', 'span.label i.icon-remove', onDeleteClick); + + }); + + + + + + + + + <% + pageScope.crumbs = [ + ] + %> + + + +
+ Please correct the following before proceeding: +
    + +
  • + ${errorMessage} +
  • +
    +
+
+
+ +
+ +
+ +
+ +
+ + Select the picklist to use for this expedition. A picklist with a specific 'Collection Code' must be loaded first +
+
+
+ +
+
+ + ${l.value} + + +
+
+
+ + Select all appropriate tags for the expedition. +
+
+ +
+
+ Cancel +  Back + +
+
+ +
+
+
+ + + diff --git a/grails-app/views/project/createNewProject/summary.gsp b/grails-app/views/project/createNewProject/summary.gsp index 16f07a1cc..0013f5850 100644 --- a/grails-app/views/project/createNewProject/summary.gsp +++ b/grails-app/views/project/createNewProject/summary.gsp @@ -114,14 +114,14 @@ Template - ${Template.get(project.templateId)?.name} + ${templateName} Expedition type - + - ${projectType.label} + ${projectTypeLabel} @@ -164,6 +164,25 @@ + + + + + + ${project.picklistId} + + + + Tags + +
+ + ${l.value} + +
+ + +
diff --git a/grails-app/views/project/editGeneralSettings.gsp b/grails-app/views/project/editGeneralSettings.gsp index ff12cda95..5520456bb 100644 --- a/grails-app/views/project/editGeneralSettings.gsp +++ b/grails-app/views/project/editGeneralSettings.gsp @@ -2,18 +2,69 @@ - + jQuery(function($) { var institutions = ; var nameToId = ; + var labelColourMap = ; var baseUrl = "${createLink(controller: 'institution', action: 'index')}"; setupInstitutionAutocomplete("#featuredOwner", "#institutionId", "#institution-link-icon", "#institution-link", institutions, nameToId, baseUrl); + labelAutocomplete("#label", "${createLink(controller: 'project', action: 'newLabels', id: projectInstance.id)}", '', function(item) { + var obj = JSON.parse(item); + var updateUrl = "${createLink(controller: 'project', action: 'addLabel', id: projectInstance.id)}"; + //showSpinner(); + $.ajax(updateUrl, {type: 'POST', data: { labelId: obj.id }}) + .done(function(data) { + $( "" ) + .addClass("label") + .addClass(labelColourMap[obj.category]) + .attr("title", obj.category) + .text(obj.value) + .append( + $( "" ) + .attr("data-label-id", obj.id) + .addClass("icon-remove") + .addClass("icon-white") + ) + .appendTo( + $( "#labels" ) + ); + }) + .fail(function() { alert("Couldn't add label")}); + //.always(hideSpinner); + return null; + }); + + function onDeleteClick(e) { + var deleteUrl = "${createLink(controller: 'project', action: 'removeLabel', id: projectInstance.id)}"; + // showSpinner(); + $.ajax(deleteUrl, {type: 'POST', data: { labelId: e.target.dataset.labelId }}) + .done(function (data) { + var t = $(e.target); + var p = t.parent("span"); + p.remove(); + }) + .fail(function() { alert("Couldn't remove label")}); + //.always(hideSpinner); + } + $('#labels').on('click', 'span.label i.icon-remove', onDeleteClick); }); - + + div#labels { + padding-top: 4px; + padding-bottom: 4px; + } + div#labels > span.label { + margin: 2px; + } + i.icon-remove { + cursor: pointer; + } + @@ -70,7 +121,13 @@
- +
+ +
+
${l.value}
+
+
+