├── system.properties ├── project ├── build.properties └── plugins.sbt ├── .jshintrc ├── conf ├── osmcacerts ├── osmcacerts_dev ├── swagger-custom-mappings.yml ├── evolutions │ └── default │ │ ├── 17.sql │ │ ├── 18.sql │ │ ├── 16.sql │ │ ├── 6.sql │ │ ├── 19.sql │ │ ├── 41.sql │ │ ├── 33.sql │ │ ├── 36.sql │ │ ├── 44.sql │ │ ├── 8.sql │ │ ├── 7.sql │ │ ├── 47.sql │ │ ├── 20.sql │ │ ├── 23.sql │ │ ├── 42.sql │ │ ├── 21.sql │ │ ├── 38.sql │ │ ├── 34.sql │ │ ├── 10.sql │ │ ├── 37.sql │ │ ├── 46.sql │ │ ├── 31.sql │ │ ├── 24.sql │ │ ├── 32.sql │ │ ├── 45.sql │ │ ├── 4.sql │ │ ├── 15.sql │ │ ├── 48.sql │ │ ├── 35.sql │ │ ├── 22.sql │ │ ├── 12.sql │ │ ├── 14.sql │ │ ├── 25.sql │ │ ├── 43.sql │ │ ├── 9.sql │ │ ├── 30.sql │ │ ├── 27.sql │ │ ├── 26.sql │ │ ├── 5.sql │ │ ├── 29.sql │ │ ├── 3.sql │ │ ├── 2.sql │ │ ├── 13.sql │ │ └── 11.sql ├── logback.xml ├── swagger.yml ├── dev.conf.example └── routes ├── app ├── org │ └── maproulette │ │ ├── cache │ │ ├── ListCacheObject.scala │ │ ├── CacheObject.scala │ │ ├── TagCacheManager.scala │ │ ├── Cache.scala │ │ ├── BasicCache.scala │ │ └── RedisCache.scala │ │ ├── models │ │ ├── Lock.scala │ │ ├── Mapillary.scala │ │ ├── utils │ │ │ ├── TransactionManager.scala │ │ │ └── ChallengeFormatters.scala │ │ ├── BaseObject.scala │ │ ├── dal │ │ │ ├── OwnerMixin.scala │ │ │ ├── DALManager.scala │ │ │ ├── VirtualProjectDAL.scala │ │ │ └── TaskHistoryDal.scala │ │ ├── TaskCluster.scala │ │ ├── Comment.scala │ │ ├── Tag.scala │ │ ├── TaskLogEntry.scala │ │ ├── VirtualChallenge.scala │ │ ├── Bundle.scala │ │ ├── TaskReview.scala │ │ ├── Project.scala │ │ ├── ClusteredPoint.scala │ │ ├── Changeset.scala │ │ └── UserNotification.scala │ │ ├── filters │ │ └── Filters.scala │ │ ├── jobs │ │ ├── JobModule.scala │ │ ├── Bootstrap.scala │ │ └── Scheduler.scala │ │ ├── exception │ │ ├── StatusMessage.scala │ │ ├── MPExceptions.scala │ │ └── MPExceptionUtil.scala │ │ ├── metrics │ │ └── Metrics.scala │ │ ├── session │ │ └── Group.scala │ │ ├── provider │ │ ├── websockets │ │ │ ├── WebSocketPublisher.scala │ │ │ ├── WebSocketProvider.scala │ │ │ ├── README.md │ │ │ └── WebSocketActor.scala │ │ ├── osm │ │ │ ├── objects │ │ │ │ ├── WayProvider.scala │ │ │ │ ├── NodeProvider.scala │ │ │ │ └── RelationProvider.scala │ │ │ └── ChangeObjects.scala │ │ └── EmailProvider.scala │ │ ├── utils │ │ ├── AnormExtension.scala │ │ ├── Crypto.scala │ │ ├── Readers.scala │ │ ├── Writers.scala │ │ └── BoundingBoxFinder.scala │ │ └── controllers │ │ ├── api │ │ ├── APIController.scala │ │ ├── VirtualProjectController.scala │ │ ├── TagController.scala │ │ ├── NotificationController.scala │ │ ├── TaskHistoryController.scala │ │ └── SurveyController.scala │ │ ├── WebsocketController.scala │ │ └── OSMChangesetController.scala └── controllers │ └── Application.scala ├── .gitignore ├── start.bat ├── start.sh ├── contributors.md ├── scripts ├── 39_upgrade.sql ├── updateOSMKeys.sh └── 39_upgrade.py ├── postman └── README.md ├── test └── org │ └── maproulette │ ├── models │ └── ChallengeSpec.scala │ └── utils │ └── TestSpec.scala └── docs ├── github_example.md └── tag_changes.md /system.properties: -------------------------------------------------------------------------------- 1 | java.runtime.version=1.8 -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.2.8 2 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "undef": false, 3 | "unused": false 4 | } 5 | -------------------------------------------------------------------------------- /conf/osmcacerts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/maproulette2/master/conf/osmcacerts -------------------------------------------------------------------------------- /conf/osmcacerts_dev: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/maproulette2/master/conf/osmcacerts_dev -------------------------------------------------------------------------------- /conf/swagger-custom-mappings.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - type: play\.api\.libs\.json\.jsvalue 3 | specAsParameter: [] 4 | specAsProperty: 5 | type: string 6 | 7 | -------------------------------------------------------------------------------- /conf/evolutions/default/17.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- We need to reset all the API Keys 5 | UPDATE users SET api_key = null;; 6 | 7 | # --- !Downs 8 | -------------------------------------------------------------------------------- /conf/evolutions/default/18.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add checkin_source column to challenges table 5 | SELECT add_drop_column('challenges', 'checkin_source', 'character varying');; 6 | 7 | # --- !Downs 8 | -------------------------------------------------------------------------------- /conf/evolutions/default/16.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add leaderboard_opt_out column to users table 5 | SELECT add_drop_column('users', 'leaderboard_opt_out', 'boolean DEFAULT FALSE');; 6 | 7 | # --- !Downs 8 | -------------------------------------------------------------------------------- /conf/evolutions/default/6.sql: -------------------------------------------------------------------------------- 1 | # --- !Ups 2 | -- Index for cleanOldTasks job 3 | SELECT create_index_if_not_exists('tasks', 'status_modified', '(status, modified)'); 4 | 5 | # --- !Downs 6 | --DROP INDEX IF EXISTS idx_tasks_status_modified; 7 | -------------------------------------------------------------------------------- /conf/evolutions/default/19.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Remove unique index on Groups 5 | DROP INDEX IF EXISTS idx_groups_name; 6 | SELECT create_index_if_not_exists('groups', 'project_id_group_type', '(project_id, group_type)');; 7 | 8 | # --- !Downs 9 | -------------------------------------------------------------------------------- /conf/evolutions/default/41.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add suggested_fix for tasks 5 | ALTER TABLE challenges ADD COLUMN has_suggested_fixes boolean DEFAULT false;; 6 | 7 | # --- !Downs 8 | ALTER TABLE challenges DROP COLUMN has_suggested_fixes;; 9 | -------------------------------------------------------------------------------- /conf/evolutions/default/33.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add unique constraint on task_review.task_id 5 | 6 | ALTER TABLE "task_review" ADD CONSTRAINT task_review_task_id UNIQUE (task_id); 7 | 8 | # --- !Downs 9 | ALTER TABLE "task_review" DROP CONSTRAINT task_review_task_id 10 | -------------------------------------------------------------------------------- /conf/evolutions/default/36.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add timestamp to user_leaderboard 5 | 6 | ALTER TABLE "user_leaderboard" ADD COLUMN created timestamp without time zone DEFAULT NOW();; 7 | 8 | 9 | # --- !Downs 10 | ALTER TABLE "user_leaderboard" DROP COLUMN created;; 11 | -------------------------------------------------------------------------------- /conf/evolutions/default/44.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | ALTER TABLE challenges ADD COLUMN data_origin_date timestamp without time zone;; 5 | 6 | UPDATE challenges set data_origin_date = last_task_refresh; 7 | 8 | # --- !Downs 9 | ALTER TABLE challenges DROP COLUMN data_origin_date;; 10 | -------------------------------------------------------------------------------- /conf/evolutions/default/8.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Column to keep track of whether old tasks in the challenge should be 5 | SELECT add_drop_column('challenges', 'owner_id', 'integer NOT NULL DEFAULT -1'); 6 | 7 | # --- !Downs 8 | --SELECT add_drop_column('challenges', 'owner_id', '', false); 9 | -------------------------------------------------------------------------------- /conf/evolutions/default/7.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Column to keep track of whether old tasks in the challenge should be 5 | SELECT add_drop_column('challenges', 'updateTasks', 'BOOLEAN DEFAULT(false)'); 6 | 7 | # --- !Downs 8 | --SELECT add_drop_column('challenges', 'updateTasks', '', false); 9 | -------------------------------------------------------------------------------- /app/org/maproulette/cache/ListCacheObject.scala: -------------------------------------------------------------------------------- 1 | package org.maproulette.cache 2 | 3 | /** 4 | * @author mcuthbert 5 | */ 6 | class ListCacheObject[T](value: List[T]) extends CacheObject[T] { 7 | override def name: String = value.toString 8 | 9 | override def id: T = value.head 10 | 11 | def list:List[T] = value 12 | } 13 | -------------------------------------------------------------------------------- /conf/evolutions/default/47.sql: -------------------------------------------------------------------------------- 1 | # --- !Ups 2 | -- Add subscription option for challenge completion notifications 3 | ALTER TABLE IF EXISTS user_notification_subscriptions ADD COLUMN challenge_completed integer NOT NULL DEFAULT 1;; 4 | 5 | # --- !Downs 6 | ALTER TABLE IF EXISTS user_notification_subscriptions DROP COLUMN challenge_completed;; 7 | -------------------------------------------------------------------------------- /conf/evolutions/default/20.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add default_basemap_id column to challenges and users tables 5 | SELECT add_drop_column('challenges', 'default_basemap_id', 'character varying');; 6 | SELECT add_drop_column('users', 'default_basemap_id', 'character varying');; 7 | 8 | # --- !Downs 9 | 10 | 11 | -------------------------------------------------------------------------------- /conf/evolutions/default/23.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Updates all challenges to a STATUS_FINISHED that have no incomplete tasks 5 | UPDATE challenges c SET status=5 WHERE c.status=3 AND 6 | 0=(SELECT COUNT(*) AS total FROM tasks 7 | WHERE tasks.parent_id=c.id AND status=0) 8 | 9 | # --- !Downs 10 | -------------------------------------------------------------------------------- /app/org/maproulette/cache/CacheObject.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.cache 4 | 5 | /** 6 | * @author mcuthbert 7 | */ 8 | trait CacheObject[Key] extends Serializable { 9 | def name: String 10 | 11 | def id: Key 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | project/project 3 | project/target 4 | target 5 | tmp 6 | .history 7 | dist 8 | /.idea 9 | /*.iml 10 | /out 11 | /.idea_modules 12 | /.classpath 13 | /.project 14 | /RUNNING_PID 15 | /.settings 16 | /.sbtserver 17 | conf/dev.conf 18 | project/.sbtserver 19 | project/.sbtserver.lock 20 | project/play-fork-run.sbt 21 | project/sbt-ui.sbt 22 | *.keystore 23 | .DS_Store 24 | -------------------------------------------------------------------------------- /conf/evolutions/default/42.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add completion_responses for tasks 5 | ALTER TABLE tasks ADD COLUMN completion_responses jsonb;; 6 | ALTER TABLE challenges ADD COLUMN exportable_properties text;; 7 | 8 | # --- !Downs 9 | ALTER TABLE tasks DROP COLUMN completion_responses;; 10 | ALTER TABLE challenges DROP COLUMN exportable_properties;; 11 | -------------------------------------------------------------------------------- /start.bat: -------------------------------------------------------------------------------- 1 | set "MR_APPLICATION_SECRET=lame-ducks-forever" 2 | set "MR_DATABASE_URL=jdbc:postgresql://localhost:5432/mp_dev?user=postgres&password=osm" 3 | set "MR_OSM_SERVER=http://api06.dev.openstreetmap.org" 4 | set "MR_OAUTH_CONSUMER_KEY=8kOhMqcXVAmD3ZoBdjNgcmd93ErztPnIPpc0xjzM" 5 | set "MR_OAUTH_CONSUMER_SECRET=bAesEwMMwZO6wMgFMqAudZ12N5caQvAAsx3FbkFc" 6 | set "MR_SUPER_KEY=test" 7 | set "MR_SUPER_ACCOUNTS=437" 8 | 9 | activator run -------------------------------------------------------------------------------- /conf/evolutions/default/21.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add last_task_refresh column to challenges table 5 | ALTER TABLE "challenges" ADD COLUMN last_task_refresh TIMESTAMP WITH TIME ZONE;; 6 | UPDATE challenges c SET last_task_refresh=created WHERE last_task_refresh IS NULL AND 0 < (SELECT COUNT(*) FROM tasks t WHERE t.parent_id = c.id LIMIT 1);; 7 | 8 | # --- !Downs 9 | ALTER TABLE "challenges" DROP COLUMN last_task_refresh;; 10 | -------------------------------------------------------------------------------- /conf/evolutions/default/38.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Remove unique constraint on name for tags and make it a unique constraint 5 | -- for name/tag_type 6 | 7 | DROP INDEX "idx_tags_name";; 8 | 9 | CREATE UNIQUE INDEX "idx_tags_name_tag_type" ON "tags" (lower("name"), "tag_type");; 10 | 11 | 12 | # -- !Downs 13 | DROP INDEX "idx_tags_name_tag_type";; 14 | 15 | SELECT create_index_if_not_exists('tags', 'name', '(lower(name))', true);; 16 | -------------------------------------------------------------------------------- /app/org/maproulette/models/Lock.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models 4 | 5 | import org.joda.time.DateTime 6 | 7 | /** 8 | * @author cuthbertm 9 | */ 10 | case class Lock(lockedTime: Option[DateTime], itemType: Int, itemId: Long, userId: Long) 11 | 12 | object Lock { 13 | def emptyLock: Lock = Lock(None, -1, -1, -1) 14 | } 15 | -------------------------------------------------------------------------------- /conf/evolutions/default/34.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | UPDATE status_actions 5 | SET project_id = challenges.parent_id 6 | FROM challenges 7 | WHERE challenge_id = challenges.id AND 8 | challenge_id IN (select distinct challenge_id 9 | from status_actions 10 | INNER JOIN challenges ON challenges.id = status_actions.challenge_id 11 | WHERE challenges.parent_id != status_actions.project_id); 12 | -------------------------------------------------------------------------------- /conf/evolutions/default/10.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add default Challenge for default Survey Answers 5 | INSERT INTO challenges (id, name, parent_id, challenge_type) VALUES (-1, 'Default Dummy Survey', 0, 2); 6 | -- Add default Valid (-1) and InValid (-2) answers for any Challenge 7 | INSERT INTO answers (id, survey_id, answer) VALUES 8 | (-1, -1, 'Valid'), 9 | (-2, -1, 'Invalid'); 10 | 11 | # --- !Downs 12 | --DELETE FROM answers WHERE id IN (-1, -2); 13 | -------------------------------------------------------------------------------- /conf/evolutions/default/37.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add tag type for tags 5 | 6 | ALTER TABLE "tags" ADD COLUMN tag_type character varying DEFAULT 'challenges';; 7 | 8 | SELECT create_index_if_not_exists('tags', 'tags_tag_type', '(tag_type)');; 9 | SELECT create_index_if_not_exists('tags', 'tags_tag_type_name', '(tag_type, name)');; 10 | 11 | UPDATE tags set tag_type = 'challenges'; 12 | 13 | # --- !Downs 14 | ALTER TABLE "tags" DROP COLUMN tag_type;; 15 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | logLevel := Level.Warn 2 | 3 | // The Typesafe repository 4 | resolvers ++= Seq( 5 | Resolver.bintrayRepo("scalaz", "releases"), 6 | Resolver.bintrayIvyRepo("iheartradio", "sbt-plugins") 7 | ) 8 | 9 | addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.7.0") 10 | 11 | addSbtPlugin("com.typesafe.sbt" % "sbt-gzip" % "1.0.2") 12 | 13 | addSbtPlugin("org.scalastyle" %% "scalastyle-sbt-plugin" % "1.0.0") 14 | 15 | addSbtPlugin("com.iheart" % "sbt-play-swagger" % "0.7.5-PLAY2.7") 16 | -------------------------------------------------------------------------------- /conf/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /conf/evolutions/default/46.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | 5 | -- To allow us to know how long a lock has been in existence once we allow lock 6 | -- times to be extended, add a created column to the "locked" table and update 7 | -- existing locks to match the creation timestamp with their current 8 | -- locked_time 9 | ALTER TABLE "locked" ADD COLUMN created timestamp without time zone DEFAULT NOW();; 10 | UPDATE locked set created=locked_time; 11 | 12 | # --- !Downs 13 | ALTER TABLE "locked" DROP COLUMN created;; 14 | -------------------------------------------------------------------------------- /app/org/maproulette/filters/Filters.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.filters 4 | 5 | import javax.inject.Inject 6 | import play.api.http.DefaultHttpFilters 7 | import play.filters.cors.CORSFilter 8 | import play.filters.gzip.GzipFilter 9 | 10 | /** 11 | * @author cuthbertm 12 | */ 13 | class Filters @Inject()(corsFilter: CORSFilter, gzipFilter: GzipFilter) 14 | extends DefaultHttpFilters(corsFilter, gzipFilter) 15 | -------------------------------------------------------------------------------- /app/org/maproulette/jobs/JobModule.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.jobs 4 | 5 | import com.google.inject.AbstractModule 6 | import play.api.libs.concurrent.AkkaGuiceSupport 7 | 8 | /** 9 | * @author cuthbertm 10 | */ 11 | class JobModule extends AbstractModule with AkkaGuiceSupport { 12 | override def configure(): Unit = { 13 | bindActor[SchedulerActor]("scheduler-actor") 14 | bind(classOf[Scheduler]).asEagerSingleton() 15 | bind(classOf[Bootstrap]).asEagerSingleton() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/org/maproulette/models/Mapillary.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models 4 | 5 | /** 6 | * @author mcuthbert 7 | */ 8 | case class MapillaryServerInfo(host: String, clientId: String, border: Double) 9 | 10 | case class MapillaryImage(key: String, 11 | lat: Double, 12 | lon: Double, 13 | url_320: String, 14 | url_640: String, 15 | url_1024: String, 16 | url_2048: String) 17 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | export MR_APPLICATION_SECRET="lame-ducks-forever" # change to unique string 4 | export MR_DATABASE_URL="jdbc:postgresql://localhost:5432/mp_dev?user=osm&password=osm" # database access string 5 | export MR_OSM_SERVER="http://api06.dev.openstreetmap.org" # OSM dev server, overrides default which is production 6 | export MR_OAUTH_CONSUMER_KEY="8kOhMqcXVAmD3ZoBdjNgcmd93ErztPnIPpc0xjzM" 7 | export MR_OAUTH_CONSUMER_SECRET="bAesEwMMwZO6wMgFMqAudZ12N5caQvAAsx3FbkFc" 8 | export MR_SUPER_KEY="test" # API key to test superuser operations with 9 | export MR_SUPER_ACCOUNTS="437" # OSM account id(s) for superuser privileges, comma separated. 10 | 11 | activator run -------------------------------------------------------------------------------- /conf/evolutions/default/31.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Change users.needs_review to be an integer type so we can have more 5 | -- variability with this setting. 6 | 7 | ALTER TABLE users ALTER COLUMN needs_review SET DEFAULT null; 8 | 9 | ALTER TABLE users ALTER COLUMN needs_review TYPE INTEGER USING 10 | CASE 11 | WHEN needs_review = true then 1 12 | WHEN needs_review = false then 0 13 | ELSE NULL 14 | END; 15 | 16 | 17 | # --- !Downs 18 | ALTER TABLE users ALTER COLUMN needs_review TYPE BOOLEAN USING 19 | CASE 20 | WHEN needs_review = 1 then true 21 | WHEN needs_review = 2 then true 22 | ELSE false 23 | END; 24 | 25 | ALTER TABLE users ALTER COLUMN needs_review SET DEFAULT false; 26 | -------------------------------------------------------------------------------- /conf/evolutions/default/24.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- New table for virtual challenges 5 | CREATE TABLE IF NOT EXISTS user_leaderboard 6 | ( 7 | month_duration integer NOT NULL, 8 | user_id integer NOT NULL, 9 | user_name character varying NULL, 10 | user_avatar_url character varying NULL, 11 | user_ranking integer NOT NULL, 12 | user_score integer NOT NULL 13 | );; 14 | 15 | CREATE TABLE IF NOT EXISTS user_top_challenges 16 | ( 17 | month_duration integer NOT NULL, 18 | user_id integer NOT NULL, 19 | challenge_id integer NOT NULL, 20 | challenge_name character varying NULL, 21 | activity integer NOT NULL 22 | );; 23 | 24 | 25 | # --- !Downs 26 | --DROP TABLE IF EXISTS user_leaderboard;; 27 | --DROP TABLE IF EXISTS user_top_challenges;; 28 | -------------------------------------------------------------------------------- /app/org/maproulette/exception/StatusMessage.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.exception 4 | 5 | import play.api.libs.json.{JsValue, Json, Reads, Writes} 6 | 7 | /** 8 | * @author cuthbertm 9 | */ 10 | trait StatusMessages { 11 | implicit val statusMessageWrites = StatusMessage.statusMessageWrites 12 | implicit val statusMessageReads = StatusMessage.statusMessageReads 13 | } 14 | 15 | case class StatusMessage(status: String, message: JsValue) 16 | 17 | object StatusMessage { 18 | implicit val statusMessageWrites: Writes[StatusMessage] = Json.writes[StatusMessage] 19 | implicit val statusMessageReads: Reads[StatusMessage] = Json.reads[StatusMessage] 20 | } 21 | -------------------------------------------------------------------------------- /conf/evolutions/default/32.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add dispute columns to user_metrics 5 | 6 | ALTER TABLE "user_metrics" ADD COLUMN total_disputed_as_mapper integer DEFAULT 0;; 7 | ALTER TABLE "user_metrics" ADD COLUMN total_disputed_as_reviewer integer DEFAULT 0;; 8 | 9 | ALTER TABLE "user_metrics_history" ADD COLUMN total_disputed_as_mapper integer DEFAULT 0;; 10 | ALTER TABLE "user_metrics_history" ADD COLUMN total_disputed_as_reviewer integer DEFAULT 0;; 11 | 12 | 13 | # --- !Downs 14 | ALTER TABLE "user_metrics" DROP COLUMN total_disputed_as_mapper;; 15 | ALTER TABLE "user_metrics" DROP COLUMN total_disputed_as_reviewer;; 16 | 17 | ALTER TABLE "user_metrics_history" DROP COLUMN total_disputed_as_mapper;; 18 | ALTER TABLE "user_metrics_history" DROP COLUMN total_disputed_as_reviewer;; 19 | -------------------------------------------------------------------------------- /conf/swagger.yml: -------------------------------------------------------------------------------- 1 | --- 2 | swagger: "2.0" 3 | info: 4 | title: "MapRoulette V2 API" 5 | description: "API for MapRoulette enabling the creation and maintenance of MapRoulette challenges" 6 | version: "2.0" 7 | contact: 8 | name: "maproulette@maproulette.org" 9 | license: 10 | name: "Apache License version 2.0" 11 | url: "http://www.apache.org/licenses/" 12 | basePath: "/api/v2" 13 | host: ${API_HOST} 14 | consumes: 15 | - application/json 16 | produces: 17 | - application/json 18 | tags: 19 | - name: "Project" 20 | - name: "Challenge" 21 | - name: "Virtual Challenge" 22 | - name: "Survey" 23 | - name: "Task" 24 | - name: "Keyword" 25 | - name: "Tag (Deprecated)" 26 | - name: "Data" 27 | - name: "User" 28 | - name: "Comment" 29 | 30 | -------------------------------------------------------------------------------- /conf/evolutions/default/45.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Update all challenges to STATUS_FINISHED (5) that have at least 1 task and 5 | -- have no tasks in CREATED (0) or SKIPPED (3) statuses 6 | -- 7 | -- Update all challenges to STATUS_READY (3) that were set to STATUS_FINISHED 8 | -- but still have at least one task in CREATED (0) or SKIPPED (3) status 9 | UPDATE challenges c SET status=5 WHERE 10 | 0 < (SELECT COUNT(*) FROM tasks where tasks.parent_id=c.id) AND 11 | 0 = (SELECT COUNT(*) AS total FROM tasks 12 | WHERE tasks.parent_id=c.id AND status IN (0, 3));; 13 | 14 | UPDATE challenges c SET status=3 WHERE 15 | c.status = 5 AND 16 | 0 < (SELECT COUNT(*) AS total FROM tasks 17 | WHERE tasks.parent_id=c.id AND status IN (0, 3));; 18 | 19 | # --- !Downs 20 | -------------------------------------------------------------------------------- /conf/evolutions/default/4.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Updating index so that we can't create duplicate tags with tasks or challenges 5 | DROP INDEX IF EXISTS idx_tags_on_tasks_task_id_tag_id; 6 | SELECT create_index_if_not_exists('tags_on_tasks', 'task_id_tag_id', '(task_id, tag_id)', true); 7 | DROP INDEX IF EXISTS idx_tags_on_challenges_challenge_id_tag_id; 8 | SELECT create_index_if_not_exists('tags_on_challenges', 'challenge_id_tag_id', '(challenge_id, tag_id)', true); 9 | 10 | # --- !Downs 11 | --DROP INDEX IF EXISTS idx_tags_on_tasks_task_id_tag_id; 12 | --SELECT create_index_if_not_exists('tags_on_tasks', 'task_id_tag_id', '(task_id, tag_id)'); 13 | --DROP INDEX IF EXISTS idx_tags_on_challenges_challenge_id_tag_id; 14 | --SELECT create_index_if_not_exists('tags_on_challenges', 'challenge_id_tag_id', '(challenge_id, tag_id)'); 15 | -------------------------------------------------------------------------------- /contributors.md: -------------------------------------------------------------------------------- 1 | MapRoulette Contributors 2 | 3 | * Martijn Van Exel ([@mvexel](https://github.com/mvexel)) 4 | * Michael Cuthbert ([@mgcuthbert](https://github.com/mgcuthbert)) 5 | * Kyle Hopkins ([@kopkins](https://github.com/Kopkins)) 6 | * Neil Rotstan ([@nrotstan](https://github.com/nrotstan)) 7 | * Kelli Rotstan ([@krotstan](https://github.com/krotstan)) 8 | * Brian Davis ([@davis20](https://github.com/davis20)) 9 | * LuisGC ([@luisgc](https://github.com/luisgc)) 10 | * Michael Glanznig ([@nebulon42](https://github.com/nebulon42)) 11 | * Kyle Hopkins ([@Kopkins](https://github.com/Kopkins)) 12 | * Joost Schouppe ([@joostschouppe](https://github.com/joostschouppe)) 13 | * Harry Wood ([@harry-wood](https://github.com/harry-wood)) 14 | * Ian McEwen ([@ianmcorvidae](https://github.com/ianmcorvidae)) 15 | * Jesse Crocker ([@JesseCrocker](https://github.com/JesseCrocker)) 16 | -------------------------------------------------------------------------------- /app/org/maproulette/models/utils/TransactionManager.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models.utils 4 | 5 | import java.sql.Connection 6 | 7 | import play.api.db.Database 8 | 9 | /** 10 | * @author cuthbertm 11 | */ 12 | trait TransactionManager { 13 | implicit val db: Database 14 | 15 | def withMRConnection[T](block: Connection => T)(implicit conn: Option[Connection] = None): T = conn match { 16 | case Some(c) => block(c) 17 | case None => this.db.withConnection { implicit c => block(c) } 18 | } 19 | 20 | def withMRTransaction[T](block: Connection => T)(implicit conn: Option[Connection] = None): T = conn match { 21 | case Some(c) => block(c) 22 | case None => this.db.withTransaction { implicit c => block(c) } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /conf/evolutions/default/15.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add trigger function that will automatically update project and challenge ID's in the task_comments table 5 | CREATE OR REPLACE FUNCTION on_task_comment_insert() RETURNS TRIGGER AS $$ 6 | BEGIN 7 | IF new.challenge_id IS NULL OR new.challenge_id = -1 THEN 8 | new.challenge_id := (SELECT parent_id FROM tasks WHERE id = new.task_id);; 9 | END IF;; 10 | IF new.project_id IS NULL OR new.project_id = -1 THEN 11 | new.project_id := (SELECT parent_id FROM challenges WHERE id = new.challenge_id);; 12 | END IF;; 13 | RETURN new;; 14 | END 15 | $$ 16 | LANGUAGE plpgsql VOLATILE;; 17 | 18 | DROP TRIGGER IF EXISTS on_task_comment_insert ON task_comments;; 19 | CREATE TRIGGER on_task_comment_insert BEFORE INSERT ON task_comments 20 | FOR EACH ROW EXECUTE PROCEDURE on_task_comment_insert();; 21 | 22 | # --- !Downs 23 | -------------------------------------------------------------------------------- /conf/evolutions/default/48.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | 5 | -- Setup constraints on virtual_project_challenges to restrict duplicate 6 | -- entries. 7 | 8 | -- Find and fix prior duplicate entries. 9 | DELETE FROM 10 | virtual_project_challenges a 11 | USING virtual_project_challenges b 12 | WHERE 13 | a.id < b.id 14 | AND a.project_id = b.project_id 15 | AND a.challenge_id = b.challenge_id; 16 | 17 | -- Add unique constraint. 18 | CREATE UNIQUE INDEX CONCURRENTLY virtual_project_challenges_projects 19 | ON virtual_project_challenges (project_id, challenge_id); 20 | 21 | ALTER TABLE virtual_project_challenges 22 | ADD CONSTRAINT unique_virtual_project_challenges_projects 23 | UNIQUE USING INDEX virtual_project_challenges_projects; 24 | 25 | # --- !Downs 26 | ALTER TABLE virtual_project_challenges 27 | DROP CONSTRAINT unique_virtual_project_challenges; 28 | -------------------------------------------------------------------------------- /app/org/maproulette/models/BaseObject.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models 4 | 5 | import org.joda.time.DateTime 6 | import org.maproulette.cache.CacheObject 7 | import org.maproulette.data.ItemType 8 | 9 | /** 10 | * Every object in the system uses this trait, with exception to the User object. This enables 11 | * a consistent workflow when used in caching, through the controllers and through the data access 12 | * layer. Simply it contains an id and name, with the id being typed but in this system it is pretty 13 | * much a long. 14 | * 15 | * @author cuthbertm 16 | */ 17 | trait BaseObject[Key] extends CacheObject[Key] { 18 | val itemType: ItemType 19 | 20 | def created: DateTime 21 | 22 | def modified: DateTime 23 | 24 | def description: Option[String] = None 25 | } 26 | -------------------------------------------------------------------------------- /conf/evolutions/default/35.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add support for virtual projectSearch 5 | ALTER TABLE projects ADD COLUMN is_virtual boolean DEFAULT false; 6 | 7 | CREATE TABLE virtual_project_challenges 8 | ( 9 | id SERIAL NOT NULL PRIMARY KEY, 10 | project_id integer NOT NULL, 11 | challenge_id integer NOT NULL, 12 | created timestamp without time zone DEFAULT NOW(), 13 | 14 | CONSTRAINT virtual_project_id_fkey FOREIGN KEY (project_id) 15 | REFERENCES projects(id) MATCH SIMPLE 16 | ON DELETE CASCADE, 17 | 18 | CONSTRAINT virtual_challenge_id_fkey FOREIGN KEY (challenge_id) 19 | REFERENCES challenges(id) MATCH SIMPLE 20 | ON DELETE CASCADE 21 | );; 22 | 23 | SELECT create_index_if_not_exists('virtual_project_challenges', 'vp_project_id', '(project_id)');; 24 | SELECT create_index_if_not_exists('virtual_project_challenges', 'vp_challenge_id', '(challenge_id)');; 25 | 26 | # --- !Downs 27 | ALTER TABLE projects DROP COLUMN is_virtual; 28 | 29 | DROP TABLE virtual_project_challenges; 30 | -------------------------------------------------------------------------------- /scripts/39_upgrade.sql: -------------------------------------------------------------------------------- 1 | do $$ 2 | declare idlist int[]; 3 | begin 4 | select array(select id from tasks where geojson is null limit 1000000) into idlist; 5 | UPDATE tasks t SET geojson = geoms.geometries FROM ( 6 | SELECT task_id, ROW_TO_JSON(fc)::JSONB AS geometries 7 | FROM ( SELECT task_id, 'FeatureCollection' AS type, ARRAY_TO_JSON(array_agg(f)) AS features 8 | FROM ( SELECT task_id, 'Feature' AS type, 9 | ST_AsGeoJSON(lg.geom)::JSONB AS geometry, 10 | HSTORE_TO_JSON(lg.properties) AS properties 11 | FROM task_geometries AS lg 12 | WHERE task_id = ANY (idlist) 13 | ) AS f GROUP BY task_id 14 | ) AS fc) AS geoms WHERE id = ANY (idlist) AND id = geoms.task_id; 15 | UPDATE tasks t SET geom = geoms.geometry, location = ST_CENTROID(geoms.geometry) 16 | FROM (SELECT task_id, ST_COLLECT(ST_MAKEVALID(geom)) AS geometry FROM ( 17 | SELECT task_id, geom FROM task_geometries WHERE task_id = ANY (idlist) 18 | ) AS innerQuery GROUP BY task_id) AS geoms WHERE id = ANY (idlist) AND id = geoms.task_id; 19 | end $$; 20 | 21 | -------------------------------------------------------------------------------- /scripts/updateOSMKeys.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Get the certificate for openstreetmap 4 | openssl s_client -showcerts -connect "www.openstreetmap.org:443" -servername www.openstreetmap.org ../conf/osm.pem 5 | keytool -delete -alias www.openstreetmap.org -keystore ../conf/osmcacerts -storepass openstreetmap 6 | keytool -importcert -noprompt -trustcacerts -alias www.openstreetmap.org -file ../conf/osm.pem -keystore ../conf/osmcacerts -storepass openstreetmap 7 | 8 | # Get the certificate for dev openstreetmap servers 9 | openssl s_client -showcerts -connect "master.apis.dev.openstreetmap.org:443" -servername master.apis.dev.openstreetmap.org ../conf/osm_dev.pem 10 | keytool -delete -alias master.apis.dev.openstreetmap.org -keystore ../conf/osmcacerts_dev -storepass openstreetmap 11 | keytool -importcert -noprompt -trustcacerts -alias master.apis.dev.openstreetmap.org -file ../conf/osm_dev.pem -keystore ../conf/osmcacerts_dev -storepass openstreetmap 12 | -------------------------------------------------------------------------------- /conf/dev.conf.example: -------------------------------------------------------------------------------- 1 | include "application.conf" 2 | 3 | db.default { 4 | url="jdbc:postgresql://localhost:5432/mp_dev" 5 | username="osm" 6 | password="osm" 7 | } 8 | maproulette { 9 | debug=true 10 | bootstrap=true 11 | 12 | # The superuser can access, modify, and delete all projects and challenges 13 | # regardless of who the owner is 14 | # The API key for superuser access 15 | 16 | super.key="CHANGE_ME" 17 | 18 | # A comma-separated list of OSM user IDs that will be superusers 19 | # This can be empty as well 20 | 21 | super.accounts="CHANGE_ME" 22 | 23 | # Your Mapillary client ID, needed for the Mapillary layer 24 | # See https://www.mapillary.com/dashboard/developers 25 | 26 | mapillary.clientId="CHANGE_ME" 27 | } 28 | 29 | osm { 30 | # The OSM server we will interact with. 31 | # Note that you need to register your OAuth app with this server as well. 32 | 33 | server="https://master.apis.dev.openstreetmap.org" 34 | 35 | # The Consumer and Secret keys as provided by your OAuth app 36 | 37 | consumerKey="CHANGE_ME" 38 | consumerSecret="CHANGE_ME" 39 | } 40 | -------------------------------------------------------------------------------- /conf/evolutions/default/22.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add popularity column to challenges 5 | ALTER TABLE "challenges" ADD COLUMN popularity INT DEFAULT FLOOR(EXTRACT(epoch from NOW()));; 6 | 7 | -- Calculate initial popularity scores 8 | -- Very basic scoring for popularity p: p = (p + t) / 2 where t is timestamp 9 | -- Initial score set to created timestamp of challenge 10 | UPDATE challenges SET popularity=FLOOR(EXTRACT(epoch from created));; 11 | 12 | DO $$ DECLARE completion_action RECORD;; 13 | BEGIN 14 | FOR completion_action IN SELECT challenge_id, FLOOR(EXTRACT(epoch FROM created)) AS createdts FROM status_actions 15 | WHERE old_status != status AND status > 0 AND status < 8 ORDER BY created ASC 16 | LOOP 17 | UPDATE challenges SET popularity = ((popularity + completion_action.createdts) / 2) 18 | WHERE id = completion_action.challenge_id;; 19 | END LOOP;; 20 | END$$;; 21 | 22 | SELECT create_index_if_not_exists('challenges', 'popularity', '(popularity)');; 23 | 24 | # --- !Downs 25 | ALTER TABLE "challenges" DROP COLUMN popularity;; 26 | -------------------------------------------------------------------------------- /app/org/maproulette/models/dal/OwnerMixin.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models.dal 4 | 5 | import java.sql.Connection 6 | 7 | import anorm._ 8 | import org.maproulette.models.BaseObject 9 | import org.maproulette.session.User 10 | 11 | /** 12 | * @author mcuthbert 13 | */ 14 | trait OwnerMixin[T <: BaseObject[_]] { 15 | this: BaseDAL[_, T] => 16 | 17 | /** 18 | * Changes the owner of the object 19 | * 20 | * @param objectId The id of the object to change the owner 21 | * @param newUserId The new users id 22 | * @param user The user making the request 23 | */ 24 | def changeOwner(objectId: Long, newUserId: Long, user: User)(implicit c: Option[Connection] = None): Unit = { 25 | // for now only super users can change the owners 26 | this.permission.hasSuperAccess(user) 27 | this.withMRConnection { implicit c => 28 | SQL"""UPDATE $tableName SET owner_id = $newUserId WHERE id = $objectId""".executeUpdate() 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/org/maproulette/metrics/Metrics.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.metrics 4 | 5 | import java.util.concurrent.TimeUnit 6 | 7 | import org.joda.time.DateTime 8 | import org.slf4j.LoggerFactory 9 | import play.api.Logger 10 | 11 | /** 12 | * @author cuthbertm 13 | */ 14 | object Metrics { 15 | def timer[T](name: String, suppress: Boolean = false)(block: () => T): T = { 16 | val start = DateTime.now() 17 | val result = block() 18 | val end = DateTime.now() 19 | val diff = end.minus(start.getMillis).getMillis 20 | val minutes = TimeUnit.MILLISECONDS.toMinutes(diff) 21 | val seconds = TimeUnit.MILLISECONDS.toSeconds(diff) - TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(diff)) 22 | val milliseconds = TimeUnit.MILLISECONDS.toMillis(diff) - TimeUnit.SECONDS.toMillis(TimeUnit.MILLISECONDS.toSeconds(diff)) 23 | if (!suppress) { 24 | LoggerFactory.getLogger(this.getClass).info(s"$name took $minutes:$seconds.$milliseconds") 25 | } 26 | result 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/org/maproulette/models/TaskCluster.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models 4 | 5 | import org.maproulette.session.SearchParameters 6 | import play.api.libs.json._ 7 | 8 | /** 9 | * Task cluster object that contains information about a cluster containing various tasks 10 | * 11 | * @author mcuthbert 12 | */ 13 | case class TaskCluster(clusterId: Int, numberOfPoints: Int, taskId: Option[Long], 14 | taskStatus: Option[Int], taskPriority: Option[Int], 15 | params: SearchParameters, point: Point, 16 | bounding: JsValue = Json.toJson("{}"), 17 | challengeIds: List[Long]) extends DefaultWrites 18 | 19 | object TaskCluster { 20 | implicit val pointWrites: Writes[Point] = Json.writes[Point] 21 | implicit val pointReads: Reads[Point] = Json.reads[Point] 22 | implicit val taskClusterWrites: Writes[TaskCluster] = Json.writes[TaskCluster] 23 | implicit val taskClusterReads: Reads[TaskCluster] = Json.reads[TaskCluster] 24 | } 25 | -------------------------------------------------------------------------------- /app/org/maproulette/session/Group.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.session 4 | 5 | import org.joda.time.DateTime 6 | import org.maproulette.data.{GroupType, ItemType} 7 | import org.maproulette.models.BaseObject 8 | import play.api.libs.json.{Json, Reads, Writes} 9 | import play.api.libs.json.JodaWrites._ 10 | import play.api.libs.json.JodaReads._ 11 | 12 | /** 13 | * @author cuthbertm 14 | */ 15 | case class Group(override val id: Long, 16 | override val name: String, 17 | projectId: Long, 18 | groupType: Int, 19 | override val created: DateTime = DateTime.now(), 20 | override val modified: DateTime = DateTime.now()) extends BaseObject[Long] { 21 | override val itemType: ItemType = GroupType() 22 | } 23 | 24 | object Group { 25 | implicit val groupWrites: Writes[Group] = Json.writes[Group] 26 | implicit val groupReads: Reads[Group] = Json.reads[Group] 27 | 28 | val TYPE_SUPER_USER = -1 29 | val TYPE_ADMIN = 1 30 | val TYPE_WRITE_ACCESS = 2 31 | val TYPE_READ_ONLY = 3 32 | } 33 | -------------------------------------------------------------------------------- /app/org/maproulette/models/Comment.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models 4 | 5 | import org.joda.time.DateTime 6 | import play.api.libs.json.{Json, Reads, Writes} 7 | import play.api.libs.json.JodaWrites._ 8 | import play.api.libs.json.JodaReads._ 9 | 10 | /** 11 | * A comment can be associated to a Task, a comment contains the osm user that made the comment, 12 | * when it was created, the Task it is associated with, the actual comment and potentially the 13 | * action that was associated with the comment. 14 | * 15 | * @author cuthbertm 16 | */ 17 | case class Comment(id: Long, 18 | osm_id: Long, 19 | osm_username: String, 20 | taskId: Long, 21 | challengeId: Long, 22 | projectId: Long, 23 | created: DateTime, 24 | comment: String, 25 | actionId: Option[Long] = None) 26 | 27 | object Comment { 28 | implicit val commentWrites: Writes[Comment] = Json.writes[Comment] 29 | implicit val commentReads: Reads[Comment] = Json.reads[Comment] 30 | } 31 | -------------------------------------------------------------------------------- /conf/evolutions/default/12.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- New table for virtual challenges 5 | CREATE TABLE IF NOT EXISTS virtual_challenges 6 | ( 7 | id SERIAL NOT NULL PRIMARY KEY, 8 | owner_id integer NOT NULL, 9 | name character varying NULL, 10 | created timestamp without time zone DEFAULT NOW(), 11 | modified timestamp without time zone DEFAULT NOW(), 12 | description character varying NULL, 13 | search_parameters character varying NOT NULL, 14 | expiry timestamp with time zone DEFAULT NOW() + INTERVAL '1 day' 15 | );; 16 | 17 | SELECT create_index_if_not_exists('virtual_challenges', 'owner_id', '(owner_id)');; 18 | 19 | CREATE TABLE IF NOT EXISTS virtual_challenge_tasks 20 | ( 21 | id SERIAL NOT NULL PRIMARY KEY, 22 | task_id integer NOT NULL, 23 | virtual_challenge_id integer NOT NULL, 24 | CONSTRAINT virtual_challenges_tasks_task_id_fkey FOREIGN KEY (task_id) 25 | REFERENCES tasks(id) MATCH SIMPLE 26 | ON UPDATE CASCADE ON DELETE CASCADE, 27 | CONSTRAINT virtual_challenges_tasks_virtual_challenge_id_fkey FOREIGN KEY (virtual_challenge_id) 28 | REFERENCES virtual_challenges(id) MATCH SIMPLE 29 | ON UPDATE CASCADE ON DELETE CASCADE 30 | );; 31 | 32 | SELECT create_index_if_not_exists('virtual_challenge_tasks', 'virtual_challenge_id', '(virtual_challenge_id)');; 33 | 34 | # --- !Downs 35 | -------------------------------------------------------------------------------- /conf/evolutions/default/14.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | SELECT add_drop_column('task_comments', 'challenge_id', 'integer NOT NULL DEFAULT -1');; 5 | SELECT add_drop_column('task_comments', 'project_id', 'integer NOT NULL DEFAULT -1');; 6 | -- update current projects before adding the constraints 7 | UPDATE task_comments SET challenge_id = (SELECT parent_id FROM tasks WHERE task_comments.task_id = id), 8 | project_id = (SELECT c.parent_id FROM challenges c 9 | INNER JOIN tasks t ON t.parent_id = c.id 10 | WHERE task_comments.task_id = t.id);; 11 | 12 | ALTER TABLE task_comments DROP CONSTRAINT IF EXISTS task_comments_challenge_id_fkey;; 13 | ALTER TABLE task_comments ADD CONSTRAINT task_comments_challenge_id_fkey 14 | FOREIGN KEY (challenge_id) REFERENCES challenges (id) MATCH SIMPLE 15 | ON UPDATE CASCADE ON DELETE CASCADE;; 16 | ALTER TABLE task_comments DROP CONSTRAINT IF EXISTS task_comments_project_id_fkey;; 17 | ALTER TABLE task_comments ADD CONSTRAINT task_comments_project_id_fkey 18 | FOREIGN KEY (project_id) REFERENCES projects (id) MATCH SIMPLE 19 | ON UPDATE CASCADE ON DELETE CASCADE;; 20 | 21 | -- Add status failure text 22 | SELECT add_drop_column('challenges', 'status_message', 'text NULL');; 23 | 24 | # --- !Downs 25 | -------------------------------------------------------------------------------- /app/org/maproulette/provider/websockets/WebSocketPublisher.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.provider.websockets 4 | 5 | import akka.actor._ 6 | import akka.cluster.pubsub.DistributedPubSub 7 | import akka.cluster.pubsub.DistributedPubSubMediator.{Publish} 8 | 9 | /** 10 | * WebSocketPublisher is an Akka actor that is responsible for publishing 11 | * server-initiated messages to the Akka mediator that manages the 12 | * publish/subscribe of the various message types to the WebSocketActor 13 | * instances that represent the client websockets. 14 | * 15 | * Note that server code should generally use the WebSocketProvider.sendMessage 16 | * method, rather than trying to access this actor directly, so that it need 17 | * not worry about interfacing with the Akka actor system. 18 | * 19 | * @author nrotstan 20 | */ 21 | class WebSocketPublisher extends Actor { 22 | val mediator = DistributedPubSub(context.system).mediator 23 | 24 | def receive = { 25 | case message: WebSocketMessages.ServerMessage => 26 | message.meta.subscriptionName match { 27 | case Some(name) => mediator ! Publish(name, message) 28 | case None => None // Ignore messages not intended for publication 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/org/maproulette/models/Tag.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models 4 | 5 | import org.joda.time.DateTime 6 | import org.maproulette.data.{ItemType, TagType} 7 | import play.api.libs.json.{Json, Reads, Writes} 8 | import play.api.libs.json.JodaWrites._ 9 | import play.api.libs.json.JodaReads._ 10 | 11 | /** 12 | * Tags sit outside of the object hierarchy and have no parent or children objects associated it. 13 | * It simply has a many to one mapping between tags and tasks. This allows tasks to be easily 14 | * searched for and organized. Helping people find tasks related to what interests them. 15 | * 16 | * @author cuthbertm 17 | */ 18 | case class Tag(override val id: Long, 19 | override val name: String, 20 | override val description: Option[String] = None, 21 | override val created: DateTime = DateTime.now(), 22 | override val modified: DateTime = DateTime.now(), 23 | tagType: String = "challenges") extends BaseObject[Long] { 24 | override val itemType: ItemType = TagType() 25 | } 26 | 27 | object Tag { 28 | implicit val tagWrites: Writes[Tag] = Json.writes[Tag] 29 | implicit val tagReads: Reads[Tag] = Json.reads[Tag] 30 | 31 | val KEY = "tags" 32 | } 33 | -------------------------------------------------------------------------------- /postman/README.md: -------------------------------------------------------------------------------- 1 | # Testing MapRoulette 2 API with Postman 2 | 3 | MapRoulette 2 can be easily tested using Postman with the [Collection File](maproulette2.postman_collection). To test the API execute the following: 4 | 5 | 1. Download Postman if you don't already have it [here](https://www.getpostman.com/) 6 | 2. Open Postman and import the [MapRoulette 2 Collection file](maproulette2.postman_collection) 7 | 3. Open the Postman runner. 8 | 4. Run tests for collection folders Project, Challenge and Tag. 9 | 10 | This will run through a variety of API calls and verify that the calls come back as expected. 11 | 12 | ### Important Note 13 | 14 | In MapRoulette Play Framework we have enabled CORS for our Swagger API documentation. So using swagger-ui you are able to view and test the API calls. However by enabling the CORS filter it causes authorized requests to fail with a 403 forbidden. This is due to Postman adding the "origin" header to the request with the value "file://". Unfortunately this is a completely invalid URI and so fails with a exception during the CORS filter process in Play. So to run the API test suites in Postman you will need to disable the CORS filter on your server prior to running the tests. To do this you simple need to comment out the following line in conf/application.conf: 15 | 16 | ``` 17 | Line 118: //play.http.filters="org.maproulette.filters.Filters" 18 | ``` 19 | -------------------------------------------------------------------------------- /app/org/maproulette/utils/AnormExtension.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.utils 4 | 5 | /** 6 | * Implicit helper conversion functions to convert Date Time values to SQL and back 7 | * 8 | * @author cuthbertm 9 | */ 10 | 11 | import anorm._ 12 | import org.joda.time._ 13 | import org.joda.time.format._ 14 | 15 | object AnormExtension { 16 | val dateFormatGeneration: DateTimeFormatter = DateTimeFormat.forPattern("yyyyMMddHHmmssSS") 17 | 18 | implicit def rowToDateTime: Column[DateTime] = Column.nonNull { (value, meta) => 19 | val MetaDataItem(qualified, nullable, clazz) = meta 20 | value match { 21 | case ts: java.sql.Timestamp => Right(new DateTime(ts.getTime)) 22 | case d: java.sql.Date => Right(new DateTime(d.getTime)) 23 | case str: java.lang.String => Right(this.dateFormatGeneration.parseDateTime(str)) 24 | case _ => Left(TypeDoesNotMatch("Cannot convert " + value + ":" + value.asInstanceOf[AnyRef].getClass)) 25 | } 26 | } 27 | 28 | implicit val dateTimeToStatement = new ToStatement[DateTime] { 29 | def set(s: java.sql.PreparedStatement, index: Int, aValue: DateTime): Unit = { 30 | s.setTimestamp(index, new java.sql.Timestamp(aValue.withMillisOfSecond(0).getMillis)) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /conf/evolutions/default/25.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add country_code column to user_leaderboard 5 | ALTER TABLE "user_leaderboard" ADD COLUMN country_code character varying NULL;; 6 | 7 | -- Add country_code column to user_top_challenges 8 | ALTER TABLE "user_top_challenges" ADD COLUMN country_code character varying NULL;; 9 | 10 | -- Geometries for a specific task 11 | CREATE TABLE IF NOT EXISTS task_suggested_fix 12 | ( 13 | id SERIAL NOT NULL PRIMARY KEY, 14 | task_id integer NOT NULL, 15 | properties HSTORE, 16 | CONSTRAINT task_suggested_fix_task_id_fkey FOREIGN KEY (task_id) 17 | REFERENCES tasks (id) MATCH SIMPLE 18 | ON UPDATE CASCADE ON DELETE CASCADE 19 | DEFERRABLE INITIALLY DEFERRED 20 | );; 21 | 22 | DO $$ 23 | BEGIN 24 | PERFORM column_name FROM information_schema.columns WHERE table_name = 'task_suggested_fix' AND column_name = 'geom';; 25 | IF NOT FOUND THEN 26 | PERFORM AddGeometryColumn('task_suggested_fix', 'geom', 4326, 'GEOMETRY', 2);; 27 | END IF;; 28 | END$$;; 29 | 30 | CREATE INDEX IF NOT EXISTS idx_task_geometries_geom ON task_suggested_fix USING GIST (geom);; 31 | SELECT create_index_if_not_exists('task_suggested_fix', 'task_id', '(task_id)');; 32 | 33 | # --- !Downs 34 | ALTER TABLE "user_leaderboard" DROP COLUMN country_code;; 35 | ALTER TABLE "user_top_challenges" DROP COLUMN country_code;; 36 | --DROP TABLE task_suggested_fix 37 | -------------------------------------------------------------------------------- /app/org/maproulette/controllers/api/APIController.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.controllers.api 4 | 5 | import javax.inject.Inject 6 | import org.maproulette.exception.{StatusMessage, StatusMessages} 7 | import org.maproulette.models.dal.DALManager 8 | import org.maproulette.session.SessionManager 9 | import play.api.libs.json.{JsString, Json} 10 | import play.api.mvc._ 11 | 12 | /** 13 | * A basic action controller for miscellaneous operations on the API 14 | * 15 | * @author cuthbertm 16 | */ 17 | class APIController @Inject()(dalManager: DALManager, 18 | sessionManager: SessionManager, 19 | components: ControllerComponents) extends AbstractController(components) with StatusMessages { 20 | /** 21 | * In the routes file this will be mapped to any /api/v2/ paths. It is the last mapping to take 22 | * place so if it doesn't match any of the other routes it will fall into this invalid path. 23 | * 24 | * @param path The path found after /api/v2 25 | * @return A json object returned with a 400 BadRequest 26 | */ 27 | def invalidAPIPath(path: String): Action[AnyContent] = Action { 28 | BadRequest(Json.toJson(StatusMessage("KO", JsString(s"Invalid Path [$path] for API")))) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/org/maproulette/models/TaskLogEntry.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models 4 | 5 | import org.joda.time.DateTime 6 | import play.api.libs.json.{DefaultWrites, Json, Reads, Writes} 7 | import play.api.libs.json.JodaWrites._ 8 | import play.api.libs.json.JodaReads._ 9 | 10 | /** 11 | * TaskLogEntry allows for showing task history actions. This includes 12 | * comment actions, status actions and review actions. 13 | * @author krotstan 14 | */ 15 | case class TaskLogEntry(taskId: Long, 16 | timestamp: DateTime, 17 | actionType: Int, 18 | user: Option[Int], 19 | oldStatus: Option[Int], 20 | status: Option[Int], 21 | startedAt: Option[DateTime], 22 | reviewStatus: Option[Int], 23 | reviewRequestedBy: Option[Int], 24 | reviewedBy: Option[Int], 25 | comment: Option[String]) { 26 | } 27 | 28 | object TaskLogEntry { 29 | implicit val taskLogEntryWrites: Writes[TaskLogEntry] = Json.writes[TaskLogEntry] 30 | implicit val taskLogEntryReads: Reads[TaskLogEntry] = Json.reads[TaskLogEntry] 31 | 32 | val ACTION_COMMENT = 0 33 | val ACTION_STATUS_CHANGE = 1 34 | val ACTION_REVIEW = 2 35 | } 36 | -------------------------------------------------------------------------------- /conf/evolutions/default/43.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- New table for bundles 5 | CREATE TABLE IF NOT EXISTS bundles 6 | ( 7 | id SERIAL NOT NULL PRIMARY KEY, 8 | owner_id integer NOT NULL, 9 | name character varying NULL, 10 | description character varying NULL, 11 | created timestamp without time zone DEFAULT NOW(), 12 | modified timestamp without time zone DEFAULT NOW(), 13 | CONSTRAINT bundles_owner_id FOREIGN KEY (owner_id) 14 | REFERENCES users(id) MATCH SIMPLE 15 | ON UPDATE CASCADE ON DELETE CASCADE 16 | );; 17 | 18 | -- New table for task bundles 19 | CREATE TABLE IF NOT EXISTS task_bundles 20 | ( 21 | id SERIAL NOT NULL PRIMARY KEY, 22 | task_id integer NOT NULL, 23 | bundle_id integer NOT NULL, 24 | CONSTRAINT task_bundles_task_id FOREIGN KEY (task_id) 25 | REFERENCES tasks(id) MATCH SIMPLE 26 | ON UPDATE CASCADE ON DELETE CASCADE, 27 | CONSTRAINT task_bundles_bundle_id FOREIGN KEY (bundle_id) 28 | REFERENCES bundles(id) MATCH SIMPLE 29 | ON UPDATE CASCADE ON DELETE CASCADE 30 | );; 31 | 32 | ALTER TABLE tasks ADD COLUMN bundle_id integer NULL;; 33 | ALTER TABLE tasks ADD COLUMN is_bundle_primary boolean;; 34 | 35 | SELECT create_index_if_not_exists('task_bundles', 'bundle_id', '(bundle_id)');; 36 | 37 | # --- !Downs 38 | ALTER TABLE tasks DROP COLUMN bundle_id;; 39 | ALTER TABLE tasks DROP COLUMN is_bundle_primary;; 40 | 41 | DROP TABLE IF EXISTS task_bundles;; 42 | DROP TABLE IF EXISTS bundles;; 43 | -------------------------------------------------------------------------------- /app/org/maproulette/models/VirtualChallenge.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models 4 | 5 | import org.joda.time.DateTime 6 | import org.maproulette.data.{ItemType, VirtualChallengeType} 7 | import org.maproulette.session.SearchParameters 8 | import play.api.libs.json.{DefaultWrites, Json, Reads, Writes} 9 | import play.api.libs.json.JodaWrites._ 10 | import play.api.libs.json.JodaReads._ 11 | 12 | /** 13 | * @author mcuthbert 14 | */ 15 | case class VirtualChallenge(override val id: Long, 16 | override val name: String, 17 | override val created: DateTime, 18 | override val modified: DateTime, 19 | override val description: Option[String] = None, 20 | ownerId: Long, 21 | searchParameters: SearchParameters, 22 | expiry: DateTime, 23 | taskIdList: Option[List[Long]] = None) extends BaseObject[Long] with DefaultWrites { 24 | 25 | override val itemType: ItemType = VirtualChallengeType() 26 | 27 | def isExpired : Boolean = DateTime.now().isAfter(expiry) 28 | } 29 | 30 | object VirtualChallenge { 31 | implicit val virtualChallengeWrites: Writes[VirtualChallenge] = Json.writes[VirtualChallenge] 32 | implicit val virtualChallengeReads: Reads[VirtualChallenge] = Json.reads[VirtualChallenge] 33 | } 34 | -------------------------------------------------------------------------------- /app/org/maproulette/cache/TagCacheManager.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.cache 4 | 5 | import java.util.concurrent.locks.{ReadWriteLock, ReentrantReadWriteLock} 6 | 7 | import anorm._ 8 | import javax.inject.{Inject, Provider, Singleton} 9 | import org.maproulette.Config 10 | import org.maproulette.models.Tag 11 | import org.maproulette.models.dal.TagDAL 12 | import play.api.db.Database 13 | 14 | /** 15 | * This is not currently a real cache manager, it will just store elements that have been loaded into memory. 16 | * Using this too much will most likely end up in OutOfMemory exceptions, so this needs to be readdressed 17 | * prior to a live version of this service. The CacheStorage is the area that you would be required 18 | * to be modified. 19 | * 20 | * @author cuthbertm 21 | */ 22 | @Singleton 23 | class TagCacheManager @Inject()(tagDAL: Provider[TagDAL], db: Database, config: Config) 24 | extends CacheManager[Long, Tag](config, Config.CACHE_ID_TAGS) { 25 | 26 | private val loadingLock: ReadWriteLock = new ReentrantReadWriteLock() 27 | 28 | def reloadTags: Unit = { 29 | this.loadingLock.writeLock().lock() 30 | try { 31 | db.withConnection { implicit c => 32 | this.cache.clear() 33 | SQL"""SELECT * FROM tags""".as(tagDAL.get.parser.*).foreach(tag => cache.addObject(tag)) 34 | } 35 | } finally { 36 | this.loadingLock.writeLock().unlock() 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/org/maproulette/models/Bundle.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models 4 | 5 | import org.joda.time.DateTime 6 | import org.maproulette.data.{ItemType, BundleType} 7 | import play.api.libs.json.{Json, Reads, Writes} 8 | import play.api.libs.json.JodaWrites._ 9 | import play.api.libs.json.JodaReads._ 10 | import play.api.libs.json._ 11 | 12 | 13 | case class Bundle(override val id: Long, 14 | val owner: Long, 15 | override val name: String = "", 16 | override val description: Option[String] = None, 17 | override val created: DateTime = DateTime.now(), 18 | override val modified: DateTime = DateTime.now()) extends BaseObject[Long] { 19 | override val itemType: ItemType = BundleType() 20 | } 21 | 22 | object Bundle { 23 | implicit val bundleWrites: Writes[Bundle] = Json.writes[Bundle] 24 | implicit val bundleReads: Reads[Bundle] = Json.reads[Bundle] 25 | 26 | val KEY = "bundles" 27 | } 28 | 29 | /** 30 | * TaskBundles serve as a simple, arbitrary grouping mechanism for tasks 31 | * 32 | * @author nrotstan 33 | */ 34 | case class TaskBundle(bundleId: Long, ownerId: Long, taskIds: List[Long], tasks:Option[List[Task]]) extends DefaultWrites 35 | object TaskBundle { 36 | implicit val taskBundleWrites: Writes[TaskBundle] = Json.writes[TaskBundle] 37 | implicit val taskBundleReads: Reads[TaskBundle] = Json.reads[TaskBundle] 38 | } 39 | -------------------------------------------------------------------------------- /app/org/maproulette/provider/osm/objects/WayProvider.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.services.osm.objects 4 | 5 | import javax.inject.{Inject, Singleton} 6 | import org.joda.time.DateTime 7 | import org.maproulette.Config 8 | import org.maproulette.cache.BasicCache 9 | import org.maproulette.services.osm.OSMType 10 | import play.api.libs.ws.WSClient 11 | 12 | import scala.concurrent.Future 13 | import scala.xml.Node 14 | 15 | /** 16 | * Service extending from the ObjectService that retrieves and caches ways 17 | * 18 | * @author mcuthbert 19 | */ 20 | @Singleton 21 | class WayProvider @Inject()(override val ws: WSClient, override val config: Config) extends ObjectProvider[VersionedWay] { 22 | val cache = new BasicCache[Long, VersionedObjects[VersionedWay]](config) 23 | 24 | def get(ids: List[Long]): Future[List[VersionedWay]] = getFromType(ids, OSMType.WAY) 25 | 26 | override protected def createVersionedObjectFromXML(elem: Node, id: Long, visible: Boolean, version: Int, changeset: Int, 27 | timestamp: DateTime, user: String, uid: Long, tags: Map[String, String]): VersionedWay = { 28 | VersionedWay( 29 | s"Node_$id", 30 | id, 31 | visible, 32 | version, 33 | changeset, 34 | timestamp, 35 | user, 36 | uid, 37 | tags, 38 | (elem \ "nd").map(v => (v \ "@ref").text.toLong).toList 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/org/maproulette/provider/osm/objects/NodeProvider.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.services.osm.objects 4 | 5 | import javax.inject.{Inject, Singleton} 6 | import org.joda.time.DateTime 7 | import org.maproulette.Config 8 | import org.maproulette.cache.BasicCache 9 | import org.maproulette.services.osm.OSMType 10 | import play.api.libs.ws.WSClient 11 | 12 | import scala.concurrent.Future 13 | import scala.xml.Node 14 | 15 | /** 16 | * Service extending from the ObjectService that retrieves and caches nodes 17 | * 18 | * @author mcuthbert 19 | */ 20 | @Singleton 21 | class NodeProvider @Inject()(override val ws: WSClient, override val config: Config) extends ObjectProvider[VersionedNode] { 22 | val cache = new BasicCache[Long, VersionedObjects[VersionedNode]](config) 23 | 24 | def get(ids: List[Long]): Future[List[VersionedNode]] = getFromType(ids, OSMType.NODE) 25 | 26 | override protected def createVersionedObjectFromXML(elem: Node, id: Long, visible: Boolean, version: Int, changeset: Int, 27 | timestamp: DateTime, user: String, uid: Long, tags: Map[String, String]): VersionedNode = { 28 | VersionedNode( 29 | s"Node_$id", 30 | id, 31 | visible, 32 | version, 33 | changeset, 34 | timestamp, 35 | user, 36 | uid, 37 | tags, 38 | (elem \ "@lat").text.toDouble, 39 | (elem \ "@lon").text.toDouble 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /conf/evolutions/default/9.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Column to keep track of whether old tasks in the challenge should be 5 | SELECT add_drop_column('challenges', 'last_updated', 'timestamp without time zone DEFAULT NOW()'); 6 | -- Add new Column to Challenge to allow users to define the checkin comments for Challenges 7 | SELECT add_drop_column('challenges', 'checkin_comment', 'character varying'); 8 | UPDATE challenges SET checkin_comment = '#maproulette #' || replace(name, ' ', '_'); 9 | 10 | CREATE TABLE IF NOT EXISTS task_comments 11 | ( 12 | id SERIAL NOT NULL PRIMARY KEY, 13 | osm_id integer NOT NULL, 14 | task_id integer NOT NULL, 15 | created timestamp without time zone DEFAULT NOW(), 16 | comment character varying, 17 | action_id integer null, 18 | CONSTRAINT task_comments_tasks_id_fkey FOREIGN KEY (task_id) 19 | REFERENCES tasks (id) MATCH SIMPLE 20 | ON UPDATE CASCADE ON DELETE CASCADE, 21 | CONSTRAINT task_comments_actions_id_fkey FOREIGN KEY (action_id) 22 | REFERENCES actions (id) MATCH SIMPLE 23 | ON UPDATE CASCADE ON DELETE SET NULL 24 | );; 25 | 26 | -- update all the last updated values 27 | DO $$ 28 | DECLARE 29 | rec RECORD;; 30 | BEGIN 31 | FOR rec IN SELECT id FROM challenges LOOP 32 | UPDATE challenges SET last_updated = (SELECT MAX(modified) 33 | FROM tasks 34 | WHERE parent_id = rec.id) 35 | WHERE id = rec.id;; 36 | END LOOP;; 37 | END$$;; 38 | UPDATE challenges SET last_updated = NOW() WHERE last_updated IS NULL;; 39 | 40 | # --- !Downs 41 | --SELECT add_drop_column('challenges', 'last_updated', '', false); 42 | -------------------------------------------------------------------------------- /app/org/maproulette/provider/websockets/WebSocketProvider.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.provider.websockets 4 | 5 | import javax.inject.{Inject, Singleton} 6 | import akka.actor._ 7 | 8 | /** 9 | * WebSocketProvider provides convenience methods for interacting with the 10 | * WebSocketPublisher to publish messages to the various client websockets by 11 | * way of an Akka mediator. Server code should interact with this class to send 12 | * messages to clients rather than trying to access actors or the mediator 13 | * directly. 14 | * 15 | * Note that the mediator is responsible for determining which client 16 | * websockets receive which messages based on each client's communicated 17 | * subscription preferences, so server code need not worry about trying to 18 | * address messages to particular clients when using the sendMessage method. 19 | * 20 | * The various supported types of messages and data formats, along with helper 21 | * methods to easily and correctly construct them, can be found in 22 | * WebSocketMessage 23 | * 24 | * @author nrotstan 25 | */ 26 | @Singleton 27 | class WebSocketProvider @Inject()(implicit system: ActorSystem) { 28 | val publisher = system.actorOf(Props[WebSocketPublisher], "publisher") 29 | 30 | def sendMessage(message: WebSocketMessages.ServerMessage) = { 31 | publisher ! message 32 | } 33 | 34 | def sendMessage(messages: List[WebSocketMessages.ServerMessage]) = { 35 | messages.foreach { publisher ! _ } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /conf/evolutions/default/30.sql: -------------------------------------------------------------------------------- 1 | # --- !Ups 2 | -- Add email address to users table 3 | ALTER TABLE "users" ADD COLUMN email character varying;; 4 | 5 | -- New table for notifications 6 | CREATE TABLE IF NOT EXISTS user_notifications 7 | ( 8 | id SERIAL NOT NULL PRIMARY KEY, 9 | user_id integer NOT NULL, 10 | notification_type integer NOT NULL, 11 | created timestamp without time zone DEFAULT NOW(), 12 | modified timestamp without time zone DEFAULT NOW(), 13 | description character varying, 14 | from_username character varying, 15 | is_read boolean DEFAULT FALSE, 16 | email_status integer NOT NULL, 17 | task_id integer, 18 | challenge_id integer, 19 | project_id integer, 20 | target_id integer, 21 | extra character varying 22 | );; 23 | 24 | SELECT create_index_if_not_exists('user_notifications', 'user_id', '(user_id)');; 25 | SELECT create_index_if_not_exists('user_notifications', 'email_status', '(email_status)');; 26 | 27 | -- New table for notification subscriptions 28 | CREATE TABLE IF NOT EXISTS user_notification_subscriptions 29 | ( 30 | id SERIAL NOT NULL PRIMARY KEY, 31 | user_id integer NOT NULL, 32 | system integer NOT NULL DEFAULT 1, 33 | mention integer NOT NULL DEFAULT 1, 34 | review_approved integer NOT NULL DEFAULT 1, 35 | review_rejected integer NOT NULL DEFAULT 1, 36 | review_again integer NOT NULL DEFAULT 1 37 | );; 38 | 39 | SELECT create_index_if_not_exists('user_notification_subscriptions', 'user_id', '(user_id)', true);; 40 | 41 | # --- !Downs 42 | DROP TABLE IF EXISTS user_notification_subscriptions;; 43 | DROP TABLE IF EXISTS user_notifications;; 44 | ALTER TABLE "users" DROP COLUMN email;; -------------------------------------------------------------------------------- /app/org/maproulette/controllers/WebsocketController.scala: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import javax.inject.Inject 4 | import org.maproulette.exception.{StatusMessage, StatusMessages} 5 | import org.maproulette.models.dal.DALManager 6 | import org.maproulette.session.SessionManager 7 | import org.maproulette.provider.websockets.WebSocketActor 8 | import org.maproulette.provider.websockets.WebSocketMessages 9 | import play.api.libs.json.{JsValue, Json} 10 | import play.api.mvc._ 11 | import play.api.mvc.WebSocket.MessageFlowTransformer 12 | import play.api.libs.streams.ActorFlow 13 | import akka.actor.ActorSystem 14 | import akka.stream.Materializer 15 | 16 | 17 | /** 18 | * @author nrotstan 19 | */ 20 | class WebSocketController @Inject()(dalManager: DALManager, 21 | sessionManager: SessionManager, 22 | components: ControllerComponents)(implicit system: ActorSystem, mat: Materializer) 23 | extends AbstractController(components) with StatusMessages { 24 | 25 | // implicit reads and writes used for various JSON messages 26 | implicit val clientMessageReads = WebSocketMessages.clientMessageReads 27 | implicit val messageFlowTransformer = MessageFlowTransformer.jsonMessageFlowTransformer[WebSocketMessages.ClientMessage, JsValue] 28 | 29 | /** 30 | * Instantiate a WebSocketActor to handle communication for a new client 31 | * websocket connection 32 | */ 33 | def socket = WebSocket.accept[WebSocketMessages.ClientMessage, JsValue] { request => 34 | ActorFlow.actorRef { out => 35 | WebSocketActor.props(out) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/org/maproulette/exception/MPExceptions.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.exception 4 | 5 | /** 6 | * Simple exception class extending exception, to handle invalid API calls. This allows us to pattern 7 | * match on the exception and if InvalidException is found we return a BadRequest instead of 8 | * an InternalServerError 9 | * 10 | * @param message The message to send with the exception 11 | */ 12 | class InvalidException(message: String) extends Exception(message) 13 | 14 | /** 15 | * NotFoundException should be throw whenever we try to retrieve an object based on the object id 16 | * and find nothing 17 | * 18 | * @param message The message to send with the exception 19 | */ 20 | class NotFoundException(message: String) extends Exception(message) 21 | 22 | /** 23 | * Exception for handling any exceptions related to locking of MapRoulette objects 24 | * 25 | * @param message The message to send with the exception 26 | */ 27 | class LockedException(message: String) extends Exception(message) 28 | 29 | /** 30 | * Exception for handling the unique violations when trying to insert objects into the database 31 | * 32 | * @param message The message to send with the exception 33 | */ 34 | class UniqueViolationException(message: String) extends Exception(message) 35 | 36 | /** 37 | * Exception for handling any conflicts found during changeset conflation 38 | * 39 | * @param message The message to send with the exception 40 | */ 41 | class ChangeConflictException(message: String) extends Exception(message) 42 | -------------------------------------------------------------------------------- /app/org/maproulette/utils/Crypto.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.utils 4 | 5 | import java.security.MessageDigest 6 | import java.util.Arrays 7 | 8 | import javax.crypto.Cipher 9 | import javax.crypto.spec.SecretKeySpec 10 | import javax.inject.{Inject, Singleton} 11 | import org.apache.commons.codec.binary.Base64 12 | import org.maproulette.Config 13 | 14 | /** 15 | * @author mcuthbert 16 | */ 17 | @Singleton 18 | class Crypto @Inject()(config: Config) { 19 | val key = config.config.get[String]("play.http.secret.key") 20 | 21 | def encrypt(value: String): String = { 22 | val cipher: Cipher = Cipher.getInstance("AES/ECB/PKCS5Padding") 23 | cipher.init(Cipher.ENCRYPT_MODE, keyToSpec) 24 | Base64.encodeBase64String(cipher.doFinal(value.getBytes("UTF-8"))) 25 | } 26 | 27 | def keyToSpec: SecretKeySpec = { 28 | var keyBytes: Array[Byte] = (Crypto.SALT + key).getBytes("UTF-8") 29 | val sha: MessageDigest = MessageDigest.getInstance("SHA-1") 30 | keyBytes = sha.digest(keyBytes) 31 | keyBytes = Arrays.copyOf(keyBytes, Crypto.BYTE_LENGTH) 32 | new SecretKeySpec(keyBytes, "AES") 33 | } 34 | 35 | def decrypt(encryptedValue: String): String = { 36 | val cipher: Cipher = Cipher.getInstance("AES/ECB/PKCS5PADDING") 37 | cipher.init(Cipher.DECRYPT_MODE, keyToSpec) 38 | new String(cipher.doFinal(Base64.decodeBase64(encryptedValue))) 39 | } 40 | } 41 | 42 | object Crypto { 43 | private val SALT: String = "jMhKlOuJnM34G6NHkqo9V010GhLAqOpF0BePojHgh1HgNg8^72k" 44 | private val BYTE_LENGTH = 16 45 | } 46 | -------------------------------------------------------------------------------- /app/org/maproulette/utils/Readers.scala: -------------------------------------------------------------------------------- 1 | package org.maproulette.utils 2 | 3 | import org.maproulette.models._ 4 | import org.maproulette.models.utils.ChallengeReads 5 | import org.maproulette.session.{Group, User} 6 | 7 | /** 8 | * @author mcuthbert 9 | */ 10 | trait Readers extends ChallengeReads { 11 | // User Readers 12 | implicit val tokenReads = User.tokenReads 13 | implicit val settingsReads = User.settingsReads 14 | implicit val userGroupReads = User.userGroupReads 15 | implicit val locationReads = User.locationReads 16 | implicit val osmReads = User.osmReads 17 | // Group Readers 18 | implicit val groupReads = Group.groupReads 19 | // Challenge Readers 20 | implicit val answerReads = Challenge.answerReads 21 | // Point Readers 22 | implicit val pointReads = ClusteredPoint.pointReads 23 | implicit val clusteredPointReads = ClusteredPoint.clusteredPointReads 24 | // Comment Readers 25 | implicit val commentReads = Comment.commentReads 26 | // Project Readers 27 | implicit val projectReads = Project.projectReads 28 | // Tag Readers 29 | implicit val tagReads = Tag.tagReads 30 | // Task Readers 31 | implicit val taskReads = Task.TaskFormat 32 | implicit val taskClusterReads = TaskCluster.taskClusterReads 33 | implicit val taskLogEntryReads = TaskLogEntry.taskLogEntryReads 34 | implicit val taskReviewReads = TaskReview.reviewReads 35 | implicit val taskWithReviewReads = TaskWithReview.taskWithReviewReads 36 | // UserNotification Readers 37 | implicit val userNotificationReads = UserNotification.notificationReads 38 | implicit val userNotificationSubscriptionsReads = NotificationSubscriptions.notificationSubscriptionReads 39 | } 40 | -------------------------------------------------------------------------------- /app/org/maproulette/provider/osm/objects/RelationProvider.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.services.osm.objects 4 | 5 | import javax.inject.{Inject, Singleton} 6 | import org.joda.time.DateTime 7 | import org.maproulette.Config 8 | import org.maproulette.cache.BasicCache 9 | import org.maproulette.services.osm.OSMType 10 | import play.api.libs.ws.WSClient 11 | 12 | import scala.concurrent.Future 13 | import scala.xml.Node 14 | 15 | /** 16 | * Service extending from the ObjectService that retrieves and caches relations 17 | * 18 | * @author mcuthbert 19 | */ 20 | @Singleton 21 | class RelationProvider @Inject()(override val ws: WSClient, override val config: Config) extends ObjectProvider[VersionedRelation] { 22 | val cache = new BasicCache[Long, VersionedObjects[VersionedRelation]](config) 23 | 24 | def get(ids: List[Long]): Future[List[VersionedRelation]] = getFromType(ids, OSMType.RELATION) 25 | 26 | override protected def createVersionedObjectFromXML(elem: Node, id: Long, visible: Boolean, version: Int, changeset: Int, 27 | timestamp: DateTime, user: String, uid: Long, tags: Map[String, String]): VersionedRelation = { 28 | VersionedRelation( 29 | s"Node_$id", 30 | id, 31 | visible, 32 | version, 33 | changeset, 34 | timestamp, 35 | user, 36 | uid, 37 | tags, 38 | (elem \ "member").map(elem => { 39 | RelationMember((elem \ "@type").text, (elem \ "@ref").text.toLong, (elem \ "@role").text) 40 | }).toList 41 | ) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/org/maproulette/utils/Writers.scala: -------------------------------------------------------------------------------- 1 | package org.maproulette.utils 2 | 3 | import org.maproulette.models._ 4 | import org.maproulette.models.utils.ChallengeWrites 5 | import org.maproulette.session.{Group, User} 6 | 7 | /** 8 | * @author mcuthbert 9 | */ 10 | trait Writers extends ChallengeWrites { 11 | // User Writers 12 | implicit val tokenWrites = User.tokenWrites 13 | implicit val settingsWrites = User.settingsWrites 14 | implicit val userGroupWrites = User.userGroupWrites 15 | implicit val locationWrites = User.locationWrites 16 | implicit val osmWrites = User.osmWrites 17 | // Group Writers 18 | implicit val groupWrites = Group.groupWrites 19 | // Challenge Writers 20 | implicit val answerWrites = Challenge.answerWrites 21 | // Point Writers 22 | implicit val pointWrites = ClusteredPoint.pointWrites 23 | implicit val clusteredPointWrites = ClusteredPoint.clusteredPointWrites 24 | // Comment Writers 25 | implicit val commentWrites = Comment.commentWrites 26 | // Project Writers 27 | implicit val projectWrites = Project.projectWrites 28 | // Tag Writers 29 | implicit val tagWrites = Tag.tagWrites 30 | // Task Writers 31 | implicit val taskWrites = Task.TaskFormat 32 | implicit val taskClusterWrites = TaskCluster.taskClusterWrites 33 | implicit val taskLogEntryWrites = TaskLogEntry.taskLogEntryWrites 34 | implicit val taskReviewWrites = TaskReview.reviewWrites 35 | implicit val taskWithReviewWrites = TaskWithReview.taskWithReviewWrites 36 | // UserNotification Writers 37 | implicit val userNotificationWrites = UserNotification.notificationWrites 38 | implicit val userNotificationSubscriptionsWrites = NotificationSubscriptions.notificationSubscriptionWrites 39 | } 40 | -------------------------------------------------------------------------------- /app/org/maproulette/models/TaskReview.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models 4 | 5 | import org.joda.time.DateTime 6 | import play.api.libs.json.{DefaultWrites, Json, Reads, Writes} 7 | import play.api.libs.json.JodaWrites._ 8 | import play.api.libs.json.JodaReads._ 9 | 10 | case class TaskReview(id: Long, 11 | taskId: Long, 12 | reviewStatus: Option[Int], 13 | challengeName: Option[String], 14 | reviewRequestedBy: Option[Long], 15 | reviewRequestedByUsername: Option[String], 16 | reviewedBy: Option[Long], 17 | reviewedByUsername: Option[String], 18 | reviewedAt: Option[DateTime], 19 | reviewStartedAt: Option[DateTime], 20 | reviewClaimedBy: Option[Long], 21 | reviewClaimedByUsername: Option[String], 22 | reviewClaimedAt: Option[DateTime]) 23 | object TaskReview { 24 | implicit val reviewWrites: Writes[TaskReview] = Json.writes[TaskReview] 25 | implicit val reviewReads: Reads[TaskReview] = Json.reads[TaskReview] 26 | } 27 | 28 | case class TaskWithReview(task: Task, review: TaskReview) 29 | object TaskWithReview { 30 | implicit val taskWithReviewWrites: Writes[TaskWithReview] = Json.writes[TaskWithReview] 31 | implicit val taskWithReviewReads: Reads[TaskWithReview] = Json.reads[TaskWithReview] 32 | } 33 | 34 | case class ReviewMetrics(total: Int, 35 | reviewRequested: Int, reviewApproved: Int, reviewRejected: Int, reviewAssisted: Int, reviewDisputed: Int, 36 | fixed: Int, falsePositive: Int, skipped: Int, alreadyFixed: Int, tooHard: Int) 37 | object ReviewMetrics { 38 | implicit val reviewMetricsWrites = Json.writes[ReviewMetrics] 39 | } 40 | -------------------------------------------------------------------------------- /app/org/maproulette/utils/BoundingBoxFinder.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.utils 4 | 5 | import javax.inject.{Inject, Singleton} 6 | import play.Environment 7 | import play.api.libs.json.{JsObject, Json} 8 | 9 | /** 10 | * Parses bounding boxes for country codes from a file. 11 | * 12 | * @author krotstan 13 | */ 14 | 15 | @Singleton 16 | class BoundingBoxFinder @Inject()(implicit val env: Environment) { 17 | private val jsonContent = scala.io.Source.fromInputStream(env.resourceAsStream("country-code-bounding-box.json")).mkString 18 | private val jsonData = Json.parse(jsonContent).as[JsObject] 19 | 20 | /** 21 | * Returns a map of country code to bounding box locations. 22 | * (ie. "US" => "-125.0, 25.0, -66.96, 49.5") 23 | */ 24 | def boundingBoxforAll(): scala.collection.mutable.Map[String, String] = { 25 | val ccDataMap = collection.mutable.Map[String, String]() 26 | 27 | // Data is in format "US" => ["United States", [-125.0, 25.0, -66.96, 49.5]] 28 | jsonData.keys.foreach(countryCode => { 29 | val result = (jsonData \ countryCode).toString 30 | val index = result.indexOf(',') 31 | 32 | // Fetch just the bounding box coordinates 33 | val boundingBox = result.slice(index + 2, result.length - 3) 34 | 35 | ccDataMap += (countryCode -> boundingBox) 36 | }) 37 | ccDataMap 38 | } 39 | 40 | /** 41 | * Returns a bounding box just for the specified country code. 42 | * (ie. "-125.0, 25.0, -66.96, 49.5") 43 | * 44 | * @param countryCode 45 | */ 46 | def boundingBoxforCountry(countryCode: String): String = { 47 | val countryCodeData = jsonData \\ countryCode 48 | 49 | var countryCodeString = countryCodeData(0)(1).toString() 50 | countryCodeString.substring(1, countryCodeString.length() - 1) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/org/maproulette/models/Project.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models 4 | 5 | import org.joda.time.DateTime 6 | import org.maproulette.data.{ItemType, ProjectType} 7 | import org.maproulette.session.{Group, User} 8 | import play.api.libs.json.{Json, Reads, Writes} 9 | import play.api.libs.json.JodaWrites._ 10 | import play.api.libs.json.JodaReads._ 11 | 12 | /** 13 | * The project object is the root object of hierarchy, it is built to allow users to have personal 14 | * domains where they can create their own challenges and have a permissions model that allows 15 | * users to have and give control over what happens within that domain. 16 | * 17 | * @author cuthbertm 18 | */ 19 | case class Project(override val id: Long, 20 | owner: Long, 21 | override val name: String, 22 | override val created: DateTime, 23 | override val modified: DateTime, 24 | override val description: Option[String] = None, 25 | groups: List[Group] = List.empty, 26 | enabled: Boolean = false, 27 | displayName: Option[String] = None, 28 | deleted: Boolean = false, 29 | isVirtual: Option[Boolean] = Some(false)) extends BaseObject[Long] { 30 | 31 | override val itemType: ItemType = ProjectType() 32 | } 33 | 34 | object Project { 35 | implicit val groupWrites: Writes[Group] = Json.writes[Group] 36 | implicit val groupReads: Reads[Group] = Json.reads[Group] 37 | implicit val projectWrites: Writes[Project] = Json.writes[Project] 38 | implicit val projectReads: Reads[Project] = Json.reads[Project] 39 | 40 | val KEY_GROUPS = "groups" 41 | 42 | def emptyProject: Project = Project(-1, User.DEFAULT_SUPER_USER_ID, "", DateTime.now(), DateTime.now()) 43 | } 44 | -------------------------------------------------------------------------------- /app/org/maproulette/jobs/Bootstrap.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.jobs 4 | 5 | import anorm._ 6 | import javax.inject.{Inject, Singleton} 7 | import org.maproulette.Config 8 | import org.slf4j.LoggerFactory 9 | import play.api.db.Database 10 | import play.api.inject.ApplicationLifecycle 11 | 12 | import scala.concurrent.Future 13 | 14 | /** 15 | * @author mcuthbert 16 | */ 17 | @Singleton 18 | class Bootstrap @Inject()(appLifeCycle: ApplicationLifecycle, db: Database, config: Config) { 19 | 20 | private val logger = LoggerFactory.getLogger(this.getClass) 21 | 22 | def start(): Unit = { 23 | if (!config.isBootstrapMode) { 24 | // for startup we make sure that all the super users are set correctly 25 | db.withConnection { implicit c => 26 | SQL"""DELETE FROM user_groups 27 | WHERE group_id = -999 AND NOT osm_user_id = -999 28 | """.executeUpdate() 29 | // make sure all the super user id's are in the super user group 30 | config.superAccounts.headOption match { 31 | case Some("*") => 32 | logger.info("WARNING: Configuration is setting all users to super users. Make sure this is what you want.") 33 | SQL"""INSERT INTO user_groups (group_id, osm_user_id) (SELECT -999 AS group_id, osm_id FROM users WHERE NOT osm_id = -999)""".executeUpdate() 34 | case Some("") => 35 | logger.info("WARNING: Configuration has NO super users. Make sure this is what you want.") 36 | case _ => 37 | val inserts = config.superAccounts.map(s => s"(-999, $s)").mkString(",") 38 | SQL"""INSERT INTO user_groups (group_id, osm_user_id) VALUES #$inserts""".executeUpdate() 39 | } 40 | } 41 | } 42 | } 43 | 44 | appLifeCycle.addStopHook { () => 45 | Future.successful(()) 46 | } 47 | 48 | start() 49 | } 50 | -------------------------------------------------------------------------------- /conf/routes: -------------------------------------------------------------------------------- 1 | # Routes 2 | # This file defines all application routes (Higher priority routes first) 3 | # ~~~~ 4 | GET /ping @controllers.Application.ping 5 | # Authentication Routes 6 | GET /auth/authenticate @controllers.AuthController.authenticate 7 | GET /auth/signout @controllers.AuthController.signOut 8 | POST /auth/signIn @controllers.AuthController.signIn(redirect:String ?= "") 9 | GET /auth/generateAPIKey @controllers.AuthController.generateAPIKey(userId:Long ?= -1) 10 | POST /auth/resetAllAPIKeys @controllers.AuthController.resetAllAPIKeys 11 | DELETE /auth/deleteUser/userId @controllers.AuthController.deleteUser(userId:Long) 12 | GET /auth/addUser/:userId/toProject/:projectId @controllers.AuthController.addUserToProject(userId:Long, projectId:Long) 13 | # Random functionality routes 14 | GET /clearCaches @controllers.Application.clearCaches 15 | PUT /runJob/:name @controllers.Application.runJob(name:String, action:String ?= "") 16 | GET /ws @controllers.WebSocketController.socket 17 | # Swagger route 18 | GET /docs/swagger-ui/*file @controllers.Assets.at(path:String="/public/lib/swagger-ui", file:String) 19 | ### NoDocs ### 20 | GET /assets/*file @controllers.Assets.versioned(path="/public", file: Asset) 21 | 22 | -> /api/v2 apiv2.Routes 23 | 24 | GET /*path/ @controllers.Application.untrail(path:String) 25 | -------------------------------------------------------------------------------- /conf/evolutions/default/27.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add needs_review, is_reviewer to user table 5 | ALTER TABLE "users" ADD COLUMN needs_review boolean DEFAULT false;; 6 | ALTER TABLE "users" ADD COLUMN is_reviewer boolean DEFAULT false;; 7 | 8 | -- Add table to keep track of review status 9 | CREATE TABLE IF NOT EXISTS task_review 10 | ( 11 | id SERIAL NOT NULL PRIMARY KEY, 12 | task_id integer NOT NULL, 13 | review_status integer, 14 | review_requested_by integer, 15 | reviewed_by integer DEFAULT NULL, 16 | reviewed_at timestamp without time zone DEFAULT NULL, 17 | review_claimed_at timestamp without time zone DEFAULT NULL, 18 | review_claimed_by integer DEFAULT NULL 19 | );; 20 | 21 | 22 | -- Add table to keep track of review history 23 | CREATE TABLE IF NOT EXISTS task_review_history 24 | ( 25 | id SERIAL NOT NULL PRIMARY KEY, 26 | task_id integer NOT NULL, 27 | requested_by integer NOT NULL, 28 | reviewed_by integer, 29 | review_status integer NOT NULL, 30 | reviewed_at timestamp without time zone DEFAULT NOW() 31 | );; 32 | 33 | ALTER TABLE "status_actions" ADD COLUMN started_at timestamp without time zone DEFAULT NULL;; 34 | 35 | SELECT create_index_if_not_exists('task_review_history', 'task_review_history_task_id', '(task_id)');; 36 | SELECT create_index_if_not_exists('task_review', 'tasks_review_id', '(task_id)');; 37 | SELECT create_index_if_not_exists('task_review', 'tasks_review_status', '(review_status)');; 38 | SELECT create_index_if_not_exists('task_review', 'tasks_reviewed_by', '(reviewed_by)');; 39 | SELECT create_index_if_not_exists('task_review', 'tasks_review_claimed_by', '(review_claimed_by)');; 40 | SELECT create_index_if_not_exists('task_review', 'tasks_review_claimed_at', '(review_claimed_at)');; 41 | 42 | # --- !Downs 43 | ALTER TABLE "users" DROP COLUMN needs_review;; 44 | ALTER TABLE "users" DROP COLUMN is_reviewer;; 45 | 46 | ALTER TABLE "status_actions" DROP COLUMN started_at;; 47 | 48 | DROP TABLE IF EXISTS task_review;; 49 | DROP TABLE IF EXISTS task_review_history;; 50 | -------------------------------------------------------------------------------- /conf/evolutions/default/26.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- New table for virtual challenges 5 | CREATE TABLE IF NOT EXISTS user_metrics 6 | ( 7 | user_id integer NOT NULL, 8 | score integer NOT NULL, 9 | total_fixed integer, 10 | total_false_positive integer, 11 | total_already_fixed integer, 12 | total_too_hard integer, 13 | total_skipped integer 14 | );; 15 | 16 | CREATE TABLE IF NOT EXISTS user_metrics_history 17 | ( 18 | user_id integer NOT NULL, 19 | score integer NOT NULL, 20 | total_fixed integer, 21 | total_false_positive integer, 22 | total_already_fixed integer, 23 | total_too_hard integer, 24 | total_skipped integer, 25 | snapshot_date timestamp without time zone DEFAULT NOW() 26 | );; 27 | 28 | SELECT create_index_if_not_exists('user_metrics', 'user_id', '(user_id)');; 29 | SELECT create_index_if_not_exists('user_metrics_history', 'user_id', '(user_id)');; 30 | SELECT create_index_if_not_exists('user_metrics_history', 'user_id_snapshot_date', '(user_id, snapshot_date)');; 31 | 32 | INSERT INTO user_metrics 33 | (user_id, score, total_fixed, total_false_positive, total_already_fixed, total_too_hard, total_skipped) 34 | SELECT users.id, 35 | SUM(CASE sa.status 36 | WHEN 1 THEN 5 37 | WHEN 2 THEN 3 38 | WHEN 5 THEN 3 39 | WHEN 6 THEN 1 40 | WHEN 3 THEN 0 41 | ELSE 0 42 | END) AS score, 43 | SUM(CASE WHEN sa.status = 1 then 1 else 0 end) total_fixed, 44 | SUM(CASE WHEN sa.status = 2 then 1 else 0 end) total_false_positive, 45 | SUM(CASE WHEN sa.status = 5 then 1 else 0 end) total_already_fixed, 46 | SUM(CASE WHEN sa.status = 6 then 1 else 0 end) total_too_hard, 47 | SUM(CASE WHEN sa.status = 3 then 1 else 0 end) total_skipped 48 | FROM status_actions sa, users 49 | WHERE users.osm_id = sa.osm_user_id AND sa.old_status <> sa.status 50 | GROUP BY sa.osm_user_id, users.id;; 51 | 52 | 53 | # --- !Downs 54 | --DROP TABLE IF EXISTS user_metrics;; 55 | --DROP TABLE IF EXISTS user_metrics_history;; 56 | -------------------------------------------------------------------------------- /app/controllers/Application.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package controllers 4 | 5 | import java.util.Calendar 6 | 7 | import akka.actor.ActorRef 8 | import javax.inject.{Inject, Named} 9 | import org.maproulette.exception.{StatusMessage, StatusMessages} 10 | import org.maproulette.jobs.SchedulerActor.RunJob 11 | import org.maproulette.models.dal._ 12 | import org.maproulette.session.SessionManager 13 | import play.api.libs.json.{JsString, Json} 14 | import play.api.mvc._ 15 | 16 | import scala.concurrent.{ExecutionContext, Future} 17 | 18 | class Application @Inject()(components: ControllerComponents, 19 | sessionManager: SessionManager, 20 | dalManager: DALManager, 21 | @Named("scheduler-actor") schedulerActor: ActorRef 22 | ) extends AbstractController(components) with StatusMessages { 23 | def untrail(path: String): Action[AnyContent] = Action { 24 | MovedPermanently(s"/$path") 25 | } 26 | 27 | def clearCaches: Action[AnyContent] = Action.async { implicit request => 28 | implicit val requireSuperUser = true 29 | sessionManager.authenticatedRequest { implicit user => 30 | dalManager.user.clearCaches 31 | dalManager.project.clearCaches 32 | dalManager.challenge.clearCaches 33 | dalManager.survey.clearCaches 34 | dalManager.task.clearCaches 35 | dalManager.tag.clearCaches 36 | Ok(Json.toJson(StatusMessage("OK", JsString("All caches cleared.")))) 37 | } 38 | } 39 | 40 | def runJob(name: String, action: String): Action[AnyContent] = Action.async { implicit request => 41 | implicit val requireSuperUser = true 42 | sessionManager.authenticatedRequest { implicit user => 43 | schedulerActor ! RunJob(name, action) 44 | Ok 45 | } 46 | } 47 | 48 | def ping(): Action[AnyContent] = Action.async { implicit request => 49 | import ExecutionContext.Implicits.global 50 | Future { 51 | Ok(s"Pong - ${Calendar.getInstance().getTime()}") 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/org/maproulette/models/dal/DALManager.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models.dal 4 | 5 | import javax.inject.{Inject, Singleton} 6 | import org.maproulette.data._ 7 | import org.maproulette.session.dal.{UserDAL, UserGroupDAL} 8 | 9 | /** 10 | * Factory that contains references to all the DAL's in the system 11 | * 12 | * @author cuthbertm 13 | */ 14 | @Singleton 15 | class DALManager @Inject()(tagDAL: TagDAL, 16 | taskDAL: TaskDAL, 17 | challengeDAL: ChallengeDAL, 18 | virtualChallengeDAL: VirtualChallengeDAL, 19 | surveyDAL: SurveyDAL, 20 | projectDAL: ProjectDAL, 21 | userDAL: UserDAL, 22 | userGroupDAL: UserGroupDAL, 23 | notificationDAL: NotificationDAL, 24 | actionManager: ActionManager, 25 | dataManager: DataManager, 26 | statusActionManager: StatusActionManager) { 27 | def tag: TagDAL = tagDAL 28 | 29 | def task: TaskDAL = taskDAL 30 | 31 | def challenge: ChallengeDAL = challengeDAL 32 | 33 | def virtualChallenge: VirtualChallengeDAL = virtualChallengeDAL 34 | 35 | def survey: SurveyDAL = surveyDAL 36 | 37 | def project: ProjectDAL = projectDAL 38 | 39 | def user: UserDAL = userDAL 40 | 41 | def userGroup: UserGroupDAL = userGroupDAL 42 | 43 | def notification: NotificationDAL = notificationDAL 44 | 45 | def action: ActionManager = actionManager 46 | 47 | def data: DataManager = dataManager 48 | 49 | def statusAction: StatusActionManager = statusActionManager 50 | 51 | def getManager(itemType: ItemType): BaseDAL[Long, _] = { 52 | itemType match { 53 | case ProjectType() => projectDAL 54 | case ChallengeType() => challengeDAL 55 | case VirtualChallengeType() => virtualChallengeDAL 56 | case SurveyType() => surveyDAL 57 | case TaskType() => taskDAL 58 | case UserType() => userDAL 59 | case TagType() => tagDAL 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /scripts/39_upgrade.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import psycopg2 3 | import sys 4 | import time 5 | 6 | hostname = 'localhost' 7 | port = 5433 8 | username = 'osm' 9 | password = 'osm' 10 | database = 'mr_07_11_19' 11 | 12 | def main(): 13 | conn = psycopg2.connect(host=hostname, user=username, password=password, dbname=database, port=port) 14 | cur = conn.cursor() 15 | cur.execute("SELECT id FROM tasks WHERE geojson IS NULL OR geom IS NULL") 16 | counter=0 17 | idList=[] 18 | for row in cur: 19 | idList.append(row[0]) 20 | counter = counter + 1 21 | if counter % 25000 == 0: 22 | stringIdList = ','.join(str(e) for e in idList) 23 | start = time.time() 24 | updateCursor = conn.cursor() 25 | updateCursor.execute(f"""UPDATE tasks t SET geojson = geoms.geometries FROM ( 26 | SELECT task_id, ROW_TO_JSON(fc)::JSONB AS geometries 27 | FROM ( SELECT task_id, 'FeatureCollection' AS type, ARRAY_TO_JSON(array_agg(f)) AS features 28 | FROM ( SELECT task_id, 'Feature' AS type, 29 | ST_AsGeoJSON(lg.geom)::JSONB AS geometry, 30 | HSTORE_TO_JSON(lg.properties) AS properties 31 | FROM task_geometries AS lg 32 | WHERE task_id IN ({stringIdList}) 33 | ) AS f GROUP BY task_id 34 | ) AS fc) AS geoms WHERE id IN ({stringIdList}) AND id = geoms.task_id""") 35 | updateCursor.execute(f""" 36 | UPDATE tasks t SET geom = geoms.geometry, location = ST_CENTROID(geoms.geometry) 37 | FROM (SELECT task_id, ST_COLLECT(ST_MAKEVALID(geom)) AS geometry FROM ( 38 | SELECT task_id, geom FROM task_geometries WHERE task_id IN ({stringIdList}) 39 | ) AS innerQuery GROUP BY task_id) AS geoms WHERE id IN ({stringIdList}) AND id = geoms.task_id 40 | """) 41 | conn.commit() 42 | updateCursor.close() 43 | end = time.time() 44 | print(f"Updated: {counter} ({len(idList)} in {end - start})") 45 | #print("For ids: ", stringIdList) 46 | idList.clear() 47 | cur.close() 48 | 49 | if __name__ == "__main__": 50 | main() 51 | -------------------------------------------------------------------------------- /app/org/maproulette/provider/osm/ChangeObjects.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.services.osm 4 | 5 | import org.maproulette.services.osm.OSMType.OSMType 6 | import org.maproulette.utils.Utils 7 | import play.api.libs.json.{Json, Reads, Writes} 8 | 9 | object OSMType extends Enumeration { 10 | type OSMType = Value 11 | val NODE, WAY, RELATION = Value 12 | 13 | override def toString(): String = this.toString().toLowerCase 14 | } 15 | 16 | /** 17 | * A class that wraps around the tag change class for submission to the API 18 | * 19 | * @param comment Comment for the changes checkin 20 | * @param changes The changes to be applied to the data 21 | */ 22 | case class TagChangeSubmission(comment: String, changes: List[TagChange]) 23 | 24 | /** 25 | * Case class that will contain all requested additions, updates and deletes. The different 26 | * actions are defined as follows: 27 | * Create - Key, Some(Value) (defined by whether the actual object as the key-value or not) 28 | * Update - Key, Some(Value) (defined by whether the actual object has the key-value or not) 29 | * Delete - Key, None 30 | * 31 | * @param osmId The id of the object you are requesting the changes for 32 | * @param osmType The type of object (node, way or relation) 33 | * @param updates The tag updates that are being requested 34 | */ 35 | case class TagChange(osmId: Long, osmType: OSMType, updates: Map[String, String], deletes: List[String], version: Option[Int] = None) 36 | 37 | /** 38 | * The results from making the requested changes to the object. 39 | * 40 | * @param osmId The id of the object you are requesting the changes for 41 | * @param osmType The type of object (node, way or relation) 42 | * @param creates A map of all newly created tags and their values 43 | * @param updates A map of all updated tags with the original value and the new value 44 | * @param deletes A map of all deleted tags and the value 45 | */ 46 | case class TagChangeResult(osmId: Long, osmType: OSMType, creates: Map[String, String], updates: Map[String, (String, String)], deletes: Map[String, String]) 47 | 48 | object ChangeObjects { 49 | implicit val enumReads = Utils.enumReads(OSMType) 50 | implicit val tagChangeReads: Reads[TagChange] = Json.reads[TagChange] 51 | implicit val tagChangeResultWrites: Writes[TagChangeResult] = Json.writes[TagChangeResult] 52 | implicit val tagChangeSubmissionReads: Reads[TagChangeSubmission] = Json.reads[TagChangeSubmission] 53 | } 54 | -------------------------------------------------------------------------------- /conf/evolutions/default/5.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Update users table for user settings 5 | SELECT add_drop_column('users', 'default_editor', 'integer DEFAULT -1'); 6 | SELECT add_drop_column('users', 'default_basemap', 'integer DEFAULT -1'); 7 | SELECT add_drop_column('users', 'custom_basemap_url', 'character varying'); 8 | SELECT add_drop_column('users', 'email_opt_in', 'boolean DEFAULT false'); 9 | SELECT add_drop_column('users', 'locale', 'character varying'); 10 | SELECT add_drop_column('users', 'theme', '', false); 11 | SELECT add_drop_column('users', 'theme', 'integer DEFAULT -1'); 12 | 13 | SELECT add_drop_column('challenges', 'default_zoom', 'integer DEFAULT 13'); 14 | SELECT add_drop_column('challenges', 'min_zoom', 'integer DEFAULT 1'); 15 | SELECT add_drop_column('challenges', 'max_zoom', 'integer DEFAULT 19'); 16 | SELECT add_drop_column('challenges', 'default_basemap', 'integer'); 17 | SELECT add_drop_column('challenges', 'custom_basemap', 'character varying'); 18 | 19 | -- Table for all challenges, which is a child of Project, Surveys are also stored in this table 20 | CREATE TABLE IF NOT EXISTS saved_challenges 21 | ( 22 | id SERIAL NOT NULL PRIMARY KEY, 23 | created timestamp without time zone DEFAULT NOW(), 24 | user_id integer NOT NULL, 25 | challenge_id integer NOT NULL, 26 | CONSTRAINT saved_challenges_user_id FOREIGN KEY (user_id) 27 | REFERENCES users(id) MATCH SIMPLE 28 | ON UPDATE CASCADE ON DELETE CASCADE, 29 | CONSTRAINT saved_challenges_challenge_id FOREIGN KEY (challenge_id) 30 | REFERENCES challenges(id) MATCH SIMPLE 31 | ON UPDATE CASCADE ON DELETE CASCADE 32 | ); 33 | SELECT create_index_if_not_exists('saved_challenges', 'user_id', '(user_id)'); 34 | SELECT create_index_if_not_exists('saved_challenges', 'user_id_challenge_id', '(user_id, challenge_id)', true); 35 | 36 | # --- !Downs 37 | --SELECT add_drop_column('users', 'default_editor', '', false); 38 | --SELECT add_drop_column('users', 'default_basemap', '', false); 39 | --SELECT add_drop_column('users', 'custom_basemap_url', '', false); 40 | --SELECT add_drop_column('users', 'email_opt_in', '', false); 41 | --SELECT add_drop_column('users', 'locale', '', false); 42 | --SELECT add_drop_column('users', 'theme', '', false); 43 | --SELECT add_drop_column('users', 'theme', 'character varying DEFAULT(''skin-blue'')'); 44 | 45 | --SELECT add_drop_column('challenges', 'default_zoom', '', false); 46 | --SELECT add_drop_column('challenges', 'min_zoom', '', false); 47 | --SELECT add_drop_column('challenges', 'max_zoom', '', false); 48 | --SELECT add_drop_column('challenges', 'default_basemap', '', false); 49 | --SELECT add_drop_column('challenges', 'custom_basemap', '', false); 50 | 51 | --DROP TABLE IF EXISTS saved_challenges; 52 | -------------------------------------------------------------------------------- /test/org/maproulette/models/ChallengeSpec.scala: -------------------------------------------------------------------------------- 1 | package org.maproulette.models 2 | 3 | import org.scalatestplus.play.PlaySpec 4 | 5 | /** 6 | * @author cuthbertm 7 | */ 8 | class ChallengeSpec() extends PlaySpec { 9 | implicit var challengeID:Long = -1 10 | 11 | "PriorityRule" should { 12 | "string types should operate correctly" in { 13 | PriorityRule("equal", "key", "value", "string").doesMatch(Map("key" -> "value")) mustEqual true 14 | PriorityRule("not_equal", "key", "value", "string").doesMatch(Map("key" -> "value2")) mustEqual true 15 | PriorityRule("contains", "key", "Value", "string").doesMatch(Map("key" -> "TheValue")) mustEqual true 16 | PriorityRule("not_contains", "key", "value", "string").doesMatch(Map("key" -> "Nothing")) mustEqual true 17 | PriorityRule("is_empty", "key", "", "string").doesMatch(Map("key" -> "")) mustEqual true 18 | PriorityRule("is_not_empty", "key", "", "string").doesMatch(Map("Key" -> "value")) mustEqual true 19 | } 20 | 21 | "integer types should operate correctly" in { 22 | PriorityRule("==", "key", "0", "integer").doesMatch(Map("key" -> "0")) mustEqual true 23 | PriorityRule("!=", "key", "0", "integer").doesMatch(Map("key" -> "1")) mustEqual true 24 | PriorityRule("<", "key", "0", "integer").doesMatch(Map("key" -> "-1")) mustEqual true 25 | PriorityRule("<=", "key", "0", "integer").doesMatch(Map("key" -> "0")) mustEqual true 26 | PriorityRule(">", "key", "0", "integer").doesMatch(Map("key" -> "1")) mustEqual true 27 | PriorityRule(">=", "key", "0", "integer").doesMatch(Map("Key" -> "0")) mustEqual true 28 | } 29 | 30 | "double types should operate correctly" in { 31 | PriorityRule("==", "key", "0", "double").doesMatch(Map("key" -> "0")) mustEqual true 32 | PriorityRule("!=", "key", "0", "double").doesMatch(Map("key" -> "1")) mustEqual true 33 | PriorityRule("<", "key", "0", "double").doesMatch(Map("key" -> "-1")) mustEqual true 34 | PriorityRule("<=", "key", "0", "double").doesMatch(Map("key" -> "0")) mustEqual true 35 | PriorityRule(">", "key", "0", "double").doesMatch(Map("key" -> "1")) mustEqual true 36 | PriorityRule(">=", "key", "0", "double").doesMatch(Map("Key" -> "0")) mustEqual true 37 | } 38 | 39 | "long types should operate correctly" in { 40 | PriorityRule("==", "key", "0", "long").doesMatch(Map("key" -> "0")) mustEqual true 41 | PriorityRule("!=", "key", "0", "long").doesMatch(Map("key" -> "1")) mustEqual true 42 | PriorityRule("<", "key", "0", "long").doesMatch(Map("key" -> "-1")) mustEqual true 43 | PriorityRule("<=", "key", "0", "long").doesMatch(Map("key" -> "0")) mustEqual true 44 | PriorityRule(">", "key", "0", "long").doesMatch(Map("key" -> "1")) mustEqual true 45 | PriorityRule(">=", "key", "0", "long").doesMatch(Map("Key" -> "0")) mustEqual true 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/org/maproulette/controllers/api/VirtualProjectController.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.controllers.api 4 | 5 | import javax.inject.Inject 6 | import org.apache.commons.lang3.StringUtils 7 | 8 | import org.maproulette.data.{ActionManager, ProjectType, TaskViewed} 9 | import org.maproulette.models.dal.{ProjectDAL, TaskDAL, VirtualProjectDAL} 10 | import org.maproulette.models.{Challenge, ClusteredPoint, Project} 11 | import org.maproulette.session.{SearchParameters, SessionManager, User} 12 | import org.maproulette.utils.Utils 13 | import play.api.libs.json._ 14 | import play.api.mvc._ 15 | 16 | /** 17 | * The virtual project controller handles all operations specific to Virtual 18 | * Project objects. It extendes ProjectController. 19 | * 20 | * See ProjectController for more details on all project object operations 21 | * 22 | * @author krotstan 23 | */ 24 | class VirtualProjectController @Inject()(override val childController: ChallengeController, 25 | override val sessionManager: SessionManager, 26 | override val actionManager: ActionManager, 27 | override val dal: ProjectDAL, 28 | virtualProjectDAL: VirtualProjectDAL, 29 | components: ControllerComponents, 30 | taskDAL: TaskDAL, 31 | override val bodyParsers: PlayBodyParsers) 32 | extends ProjectController(childController, sessionManager, actionManager, dal, components, taskDAL, bodyParsers) { 33 | 34 | /** 35 | * Adds a challenge to a virtual project. This requires Write access on the project 36 | * 37 | * @param projectId The virtual project to add the challenge to 38 | * @param challengeId The challenge that you are adding 39 | * @return Ok with no message 40 | */ 41 | def addChallenge(projectId: Long, challengeId: Long): Action[AnyContent] = Action.async { implicit request => 42 | sessionManager.authenticatedRequest { implicit user => 43 | virtualProjectDAL.addChallenge(projectId, challengeId, user) 44 | Ok 45 | } 46 | } 47 | 48 | /** 49 | * Removes a challenge from a virtual project. This requires Write access on the project 50 | * 51 | * @param projectId The virtual project to remove the challenge from 52 | * @param challengeId The challenge that you are removing 53 | * @return Ok with no message 54 | */ 55 | def removeChallenge(projectId: Long, challengeId: Long): Action[AnyContent] = Action.async { implicit request => 56 | sessionManager.authenticatedRequest { implicit user => 57 | virtualProjectDAL.removeChallenge(projectId, challengeId, user) 58 | Ok 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/org/maproulette/provider/EmailProvider.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.provider 4 | 5 | import play.api.libs.mailer._ 6 | import java.io.File 7 | import org.apache.commons.mail.EmailAttachment 8 | import javax.inject.{Inject, Singleton} 9 | import org.maproulette.Config 10 | import org.maproulette.models.{UserNotification, UserNotificationEmail} 11 | import scala.concurrent.{Future} 12 | 13 | /** 14 | * @author nrotstan 15 | * 16 | * TODO: internationalize these messages and move them out into templates 17 | */ 18 | @Singleton 19 | class EmailProvider @Inject() (mailerClient: MailerClient, config: Config) { 20 | 21 | import scala.concurrent.ExecutionContext.Implicits.global 22 | 23 | def emailNotification(toAddress: String, notification: UserNotificationEmail) = { 24 | val notificationName = UserNotification.notificationTypeMap.get(notification.notificationType).get 25 | val emailSubject = s"New MapRoulette notification: ${notificationName}" 26 | val emailBody = s""" 27 | |You have received a new MapRoulette notification: 28 | | 29 | |${notificationName} 30 | |${this.notificationFooter}""".stripMargin 31 | 32 | val email = Email(emailSubject, config.getEmailFrom.get, Seq(toAddress), bodyText = Some(emailBody)) 33 | mailerClient.send(email) 34 | } 35 | 36 | def emailNotificationDigest(toAddress: String, notifications: List[UserNotificationEmail]) = { 37 | val notificationNames = notifications.map(notification => 38 | UserNotification.notificationTypeMap.get(notification.notificationType).get 39 | ) 40 | val notificationNameCounts = notificationNames.groupBy(identity).mapValues(_.size) 41 | val notificationLines = notificationNameCounts.foldLeft("") { 42 | (s: String, pair: (String, Int)) => s + pair._1 + " (" + pair._2 + ")\n" 43 | } 44 | val emailSubject = s"MapRoulette Notifications Daily Digest" 45 | val emailBody = s""" 46 | |You have received new MapRoulette notifications over the past day: 47 | | 48 | |${notificationLines}${this.notificationFooter}""".stripMargin 49 | 50 | val email = Email(emailSubject, config.getEmailFrom.get, Seq(toAddress), bodyText = Some(emailBody)) 51 | mailerClient.send(email) 52 | } 53 | 54 | private def notificationFooter: String = { 55 | val urlPrefix = config.getPublicOrigin.get 56 | s""" 57 | |You can view your notifications by visiting your MapRoulette Inbox at: 58 | |${urlPrefix}/inbox 59 | | 60 | |Happy mapping! 61 | |--The MapRoulette Team 62 | | 63 | | 64 | |P.S. You received this because you asked to be emailed when you 65 | |received this type of notification in MapRoulette. You can manage 66 | |your notification subscriptions and email preferences at: 67 | |${urlPrefix}/profile""".stripMargin 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /docs/github_example.md: -------------------------------------------------------------------------------- 1 | # Create Challenge using Github 2 | 3 | One option of creating a challenge that was only mentioned briefly was the ability to create Challenges from Github. The way this works is that the API request will point to a specific repo and retrieve specific files that will be used to create the challenge and the tasks for the challenge. The API request is as follows: 4 | 5 | ``` 6 | POST /api/v2/project/{projectId}/challenge/{username}/{repo}/{name} 7 | HEADER apiKey = 8 | ``` 9 | 10 | ##### Parameters: 11 | - **projectId** - The id of the project that you are creating the challenge under 12 | - **username** - The organization or username of the repo 13 | - **repo** - The name of the repo that contains the file to create the challenge from. 14 | - **name** - The prefix that will be used for the three files that MapRoulette will look for when creating the challenge. 15 | 16 | ##### Github Files: 17 | - **{name}_geojson.json** - The geojson file with the prefixed {name}. See GeoJSON structure above for specifics on what kind of file is expected. 18 | - **{name}_info.md** - The url of the info file will be included in the Challenge as a file that can be used as extended information on the challenge. 19 | - **{name}_create.json** - The json used to create the Challenge. The JSON would be exactly the same as the JSON that you would provide in the body of the ```POST /api/v2/challenge``` API request. 20 | 21 | So for this example we will assume there is a repo called ```https://github.com/maproulette/example``` which contains the following files: 22 | - example_info.md - Contents of this file simply contains extended descriptive markdown. 23 | - example_geojson.json - Contents of file are exactly the same as the example GeoJSON file found [here](example.geojson). 24 | - example_create.json - Contents of the file are as below: 25 | ```json 26 | { 27 | "name":"Github Project", 28 | "instruction":"Instruction for the example challenge" 29 | } 30 | ``` 31 | First we need to create a project for our Challenge: 32 | ``` 33 | POST http://localhost:9000/api/v2/project 34 | HEADER apiKey = user1_apikey 35 | BODY { 36 | "name":"Example Github Project", 37 | "description":"Project for Github Example.", 38 | "enabled":true 39 | } 40 | RESPONSE { 41 | "id": 1327505, 42 | "owner": -999, 43 | "name": "Example Github Project", 44 | "created": "2018-05-29T16:10:27.688-07:00", 45 | "modified": "2018-05-29T16:10:27.688-07:00", 46 | "description": "Project for Github Example.", 47 | "groups": [], 48 | "enabled": true, 49 | "deleted": false 50 | } 51 | ``` 52 | ***NOTE:** The owner id will match the ID of the user used based on the APIKey.
53 | ***NOTE:** The id in the response will be different when executing this example. 54 | 55 | To create the challenge we execute the following API request: 56 | ``` 57 | POST http://localhost:9000/api/v2/project/1327505/challenge/maproulette/example/example 58 | HEADER apiKey = user1_apikey 59 | ``` 60 | 61 | After executing this request you should have a new Challenge called "Github Project". 62 | -------------------------------------------------------------------------------- /app/org/maproulette/models/ClusteredPoint.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models 4 | 5 | import org.joda.time.DateTime 6 | import play.api.libs.json.{JsValue, Json, Reads, Writes} 7 | import play.api.libs.json.JodaWrites._ 8 | import play.api.libs.json.JodaReads._ 9 | 10 | /** 11 | * @author cuthbertm 12 | */ 13 | case class Point(lat: Double, lng: Double) 14 | 15 | case class PointReview(reviewStatus: Option[Int], reviewRequestedBy: Option[Long], reviewedBy: Option[Long], 16 | reviewedAt: Option[DateTime], reviewStartedAt: Option[DateTime]) 17 | 18 | /** 19 | * This is the clustered point that will be displayed on the map. The popup will contain the title 20 | * of object with a blurb or description of said object. If the object is a challenge then below 21 | * that will be a start button so you can jump into editing tasks in the challenge 22 | * 23 | * @param id The id of the object for this clustered point 24 | * @param owner The osm id of the owner of the object 25 | * @param ownerName The name of the owner 26 | * @param title The title of the object (or name) 27 | * @param parentId The id of the parent, Challenge if Task, and Project if Challenge 28 | * @param parentName The name of the parent 29 | * @param point The latitude and longitude of the point 30 | * @param blurb A short descriptive text for the object 31 | * @param modified The last time this set of points was modified 32 | * @param difficulty The difficulty level of this ClusteredPoint (if a challenge) 33 | * @param type The type of this ClusteredPoint 34 | * @param status The status of the task, only used for task points, ie. not challenge points 35 | * @param mappedOn The date this task was mapped 36 | * @param pointReview a PointReview instance with review data 37 | * @param bundleId id of bundle task is member of, if any 38 | * @param isBundlePrimary whether task is primary task in bundle (if a member of a bundle) 39 | */ 40 | case class ClusteredPoint(id: Long, owner: Long, ownerName: String, title: String, parentId: Long, parentName: String, 41 | point: Point, bounding: JsValue, blurb: String, modified: DateTime, difficulty: Int, 42 | `type`: Int, status: Int, suggestedFix: Option[String] = None, mappedOn: Option[DateTime], 43 | pointReview: PointReview, priority: Int, 44 | bundleId: Option[Long]=None, isBundlePrimary: Option[Boolean]=None) 45 | 46 | object ClusteredPoint { 47 | implicit val pointWrites: Writes[Point] = Json.writes[Point] 48 | implicit val pointReads: Reads[Point] = Json.reads[Point] 49 | implicit val pointReviewWrites: Writes[PointReview] = Json.writes[PointReview] 50 | implicit val pointReviewReads: Reads[PointReview] = Json.reads[PointReview] 51 | implicit val clusteredPointWrites: Writes[ClusteredPoint] = Json.writes[ClusteredPoint] 52 | implicit val clusteredPointReads: Reads[ClusteredPoint] = Json.reads[ClusteredPoint] 53 | } 54 | -------------------------------------------------------------------------------- /conf/evolutions/default/29.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add needs_review, is_reviewer to user table 5 | ALTER TABLE "user_metrics" ADD COLUMN initial_rejected integer DEFAULT 0;; 6 | ALTER TABLE "user_metrics" ADD COLUMN initial_approved integer DEFAULT 0;; 7 | ALTER TABLE "user_metrics" ADD COLUMN initial_assisted integer DEFAULT 0;; 8 | ALTER TABLE "user_metrics" ADD COLUMN total_rejected integer DEFAULT 0;; 9 | ALTER TABLE "user_metrics" ADD COLUMN total_approved integer DEFAULT 0;; 10 | ALTER TABLE "user_metrics" ADD COLUMN total_assisted integer DEFAULT 0;; 11 | 12 | ALTER TABLE "user_metrics_history" ADD COLUMN initial_rejected integer DEFAULT 0;; 13 | ALTER TABLE "user_metrics_history" ADD COLUMN initial_approved integer DEFAULT 0;; 14 | ALTER TABLE "user_metrics_history" ADD COLUMN initial_assisted integer DEFAULT 0;; 15 | ALTER TABLE "user_metrics_history" ADD COLUMN total_rejected integer DEFAULT 0;; 16 | ALTER TABLE "user_metrics_history" ADD COLUMN total_approved integer DEFAULT 0;; 17 | ALTER TABLE "user_metrics_history" ADD COLUMN total_assisted integer DEFAULT 0;; 18 | 19 | ALTER TABLE "user_metrics" ADD CONSTRAINT user_metric_primary_key PRIMARY KEY (user_id); 20 | 21 | -- This fixes bug where new users since the original creation of user_metrics where not having 22 | -- their score tallied. 23 | INSERT INTO user_metrics 24 | (user_id, score, total_fixed, total_false_positive, total_already_fixed, total_too_hard, total_skipped) 25 | SELECT users.id, 26 | SUM(CASE sa.status 27 | WHEN 1 THEN 5 28 | WHEN 2 THEN 3 29 | WHEN 5 THEN 3 30 | WHEN 6 THEN 1 31 | WHEN 3 THEN 0 32 | ELSE 0 33 | END) AS score, 34 | SUM(CASE WHEN sa.status = 1 then 1 else 0 end) total_fixed, 35 | SUM(CASE WHEN sa.status = 2 then 1 else 0 end) total_false_positive, 36 | SUM(CASE WHEN sa.status = 5 then 1 else 0 end) total_already_fixed, 37 | SUM(CASE WHEN sa.status = 6 then 1 else 0 end) total_too_hard, 38 | SUM(CASE WHEN sa.status = 3 then 1 else 0 end) total_skipped 39 | FROM status_actions sa, users 40 | WHERE users.osm_id = sa.osm_user_id AND sa.old_status <> sa.status 41 | AND users.id NOT IN (select user_id from user_metrics) 42 | GROUP BY sa.osm_user_id, users.id;; 43 | 44 | # --- !Downs 45 | ALTER TABLE "user_metrics" DROP COLUMN initial_rejected;; 46 | ALTER TABLE "user_metrics" DROP COLUMN initial_approved;; 47 | ALTER TABLE "user_metrics" DROP COLUMN initial_assisted;; 48 | ALTER TABLE "user_metrics" DROP COLUMN total_rejected;; 49 | ALTER TABLE "user_metrics" DROP COLUMN total_approved;; 50 | ALTER TABLE "user_metrics" DROP COLUMN total_assisted;; 51 | 52 | ALTER TABLE "user_metrics_history" DROP COLUMN initial_rejected;; 53 | ALTER TABLE "user_metrics_history" DROP COLUMN initial_approved;; 54 | ALTER TABLE "user_metrics_history" DROP COLUMN initial_assisted;; 55 | ALTER TABLE "user_metrics_history" DROP COLUMN total_rejected;; 56 | ALTER TABLE "user_metrics_history" DROP COLUMN total_approved;; 57 | ALTER TABLE "user_metrics_history" DROP COLUMN total_assisted;; 58 | 59 | ALTER TABLE "user_metrics" DROP CONSTRAINT user_metric_primary_key 60 | -------------------------------------------------------------------------------- /conf/evolutions/default/3.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | DO $$ 5 | BEGIN 6 | PERFORM column_name FROM information_schema.columns WHERE table_name = 'challenges' AND column_name = 'location';; 7 | IF NOT FOUND THEN 8 | PERFORM AddGeometryColumn('challenges', 'location', 4326, 'POINT', 2);; 9 | END IF;; 10 | END$$;; 11 | 12 | -- Updating all the locations for all the tasks in the system. This process takes about 2 minutes or 13 | -- so depending on the amount of tasks and geometries in the system 14 | DO $$ 15 | DECLARE 16 | rec RECORD;; 17 | BEGIN 18 | FOR rec IN SELECT task_id, ST_Centroid(ST_Collect(ST_Makevalid(geom))) AS location 19 | FROM task_geometries tg 20 | GROUP BY task_id LOOP 21 | UPDATE tasks SET location = rec.location WHERE tasks.id = rec.task_id;; 22 | END LOOP;; 23 | END$$;; 24 | 25 | -- Update all the challenge locations based on the locations of their respective tasks. This process 26 | -- is fairly quick due to the update of the tasks in the previous statement 27 | DO $$ 28 | DECLARE 29 | rec RECORD;; 30 | BEGIN 31 | FOR rec IN SELECT id FROM challenges LOOP 32 | UPDATE challenges SET location = (SELECT ST_Centroid(ST_Collect(ST_Makevalid(location))) 33 | FROM tasks 34 | WHERE parent_id = rec.id) 35 | WHERE id = rec.id;; 36 | END LOOP;; 37 | END$$;; 38 | 39 | 40 | -- Modifying this function so that if you send in the default status it ignores it and updates it with the current status of the task. 41 | -- This is done primarily because the only way that a existing task should have be reset to the default status (CREATED) is if the 42 | -- task is being uploaded as part of a scheduled upload and it is past the set time that defines that this task should be rechecked 43 | CREATE OR REPLACE FUNCTION update_task(task_name text, task_parent_id bigint, task_instruction text, task_status integer, task_id bigint DEFAULT -1, task_priority integer DEFAULT 0, reset_interval text DEFAULT '7 days') RETURNS integer as $$ 44 | DECLARE 45 | update_id integer;; 46 | update_modified timestamp without time zone;; 47 | update_status integer;; 48 | new_status integer;; 49 | BEGIN 50 | IF (SELECT task_id) = -1 THEN 51 | SELECT id, modified, status INTO update_id, update_modified, update_status FROM tasks WHERE name = task_name AND parent_id = task_parent_id;; 52 | ELSE 53 | SELECT id, modified, status INTO update_id, update_modified, update_status FROM tasks WHERE id = task_id;; 54 | END IF;; 55 | IF task_status = 0 THEN 56 | task_status = update_status;; 57 | END IF;; 58 | new_status := task_status;; 59 | IF update_status = task_status AND (SELECT AGE(NOW(), update_modified)) > reset_interval::INTERVAL THEN 60 | new_status := 0;; 61 | END IF;; 62 | UPDATE tasks SET name = task_name, instruction = task_instruction, status = new_status, priority = task_priority WHERE id = update_id;; 63 | RETURN update_id;; 64 | END 65 | $$ 66 | LANGUAGE plpgsql VOLATILE;; 67 | 68 | # --- !Downs 69 | --DO $$ 70 | --BEGIN 71 | -- PERFORM column_name FROM information_schema.columns WHERE table_name = 'challenges' AND column_name = 'location';; 72 | -- IF FOUND THEN 73 | -- SELECT DropGeometryColumn('challenges', 'location');; 74 | -- END IF;; 75 | --END$$;; 76 | -------------------------------------------------------------------------------- /docs/tag_changes.md: -------------------------------------------------------------------------------- 1 | # OSM Changeset API 2 | 3 | This API in MapRoulette allows users to submit tag changes to the server, and then MapRoulette will attempt to conflate those changes with current data in OpenStreetMap. This API is primarily for support for the feature in MapRoulette called Suggested Fixes. Suggested fixes allow challenges to be created that ask the user if a change is valid or not. And if it is then MapRoulette can submit those changes directly to OpenStreetMap without the user having to make edits in JOSM or iD. This document however focuses on the API that is used to make those calls and what is going on behind the scenes and explain the workflow to the user. 4 | 5 | ### Tag Changes 6 | 7 | The Tag Changes, are JSON based delta changes that would then be applied to specific elements in the data. And as this is limited to only tag based changes, conflation is a little easier, however contains specific rules for what to do under what circumstances. 8 | 9 | A TagChange object looks as follows: 10 | ```json 11 | { 12 | "osmId":int, 13 | "osmType":OSMType, 14 | "updates":Map[String, String], 15 | "deletes":List[String], 16 | "version":Option[Int] 17 | } 18 | ``` 19 | 20 | - osmId - This is the ID of the feature that you are modifying 21 | - osmType - The type of object, either NODE, WAY or RELATION 22 | - updates - The tag updates that you wish to make. A series of key value pairs, the conflation on the backend will figure out if the update is an update or a new tag. In the delta or osmchange responses it will show whether it is treating the tag as a new tag or an updated tag. 23 | - deletes - A list of tag keys that you want to delete from the OSM feature. 24 | - version - Optionally you can set an object version, so this would be what version of the object your changes are based on. Currently this is not used, however in the future the idea would be that if the version is not the same as the current version we would simply return a conflict and not even try to upload them. 25 | 26 | A TagChangeSubmission is an object that you would submit when wanting to actually submit your changes to OpenStreetMap. This would go into the body of your request. 27 | ```json 28 | { 29 | "comment":string, 30 | "changes":List[TagChange] 31 | } 32 | ``` 33 | 34 | - comment - The comment that is going to be associated with this change. 35 | - changes - A list of TagChanges, that is described above. 36 | 37 | ## API 38 | 39 | ### Testing Changes 40 | 41 | ``` 42 | POST /api/v2/change/tag/test?changeType= 43 | HEADER or for Authentication 44 | BODY { 45 | ... // See TagChange, in the body you would simply provide an array of TagChanges 46 | } 47 | ``` 48 | This API will allow you to test your changes. The response from the server will either be a set of delta changes or the OSMChange that would be submitted to the server (minus the changesetId) when you actually submit these changes. 49 | 50 | ### Submitting Changes 51 | 52 | ``` 53 | POST /api/v2/change/tag/submit 54 | HEADER or for Authentication 55 | BODY { 56 | ... // See TagChangeSubmission 57 | } 58 | ``` 59 | This API will submit the changes to the server. The response will be the actual OSMChange that was successfully submitted. Any conflicts will be reported with a 409 Conflict HTTP response. 60 | -------------------------------------------------------------------------------- /app/org/maproulette/models/Changeset.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models 4 | 5 | import org.apache.commons.lang3.StringUtils 6 | import org.joda.time.DateTime 7 | import org.slf4j.LoggerFactory 8 | import play.api.Logger 9 | 10 | import scala.xml._ 11 | 12 | 13 | /** 14 | * @author mcuthbert 15 | */ 16 | case class Changeset(id: Long, 17 | user: String, 18 | userId: Long, 19 | createdAt: DateTime, 20 | closedAt: DateTime, 21 | open: Boolean, 22 | minLat: Double, 23 | minLon: Double, 24 | maxLat: Double, 25 | maxLon: Double, 26 | commentsCount: Int, 27 | tags: Map[String, String], 28 | hasMapRouletteComment: Boolean) 29 | 30 | object ChangesetParser { 31 | private val logger = LoggerFactory.getLogger(this.getClass) 32 | 33 | def parse(el: Elem): List[Changeset] = { 34 | (el \\ "changeset").map { c => this.parse(c) }.toList 35 | } 36 | 37 | def parse(changeSetElement: Node): Changeset = { 38 | // for some reason the open value is sometimes an empty string, so need to handle that correctly 39 | val open = (changeSetElement \ "@open").text 40 | if (StringUtils.isEmpty(open)) { 41 | logger.debug(s"Invalid changeset provided: ${changeSetElement.toString()}") 42 | } 43 | val minLat = (changeSetElement \ "@min_lat").text 44 | if (StringUtils.isEmpty(minLat)) { 45 | logger.debug(s"Invalid changeset provided: ${changeSetElement.toString()}") 46 | } 47 | val minLon = (changeSetElement \ "@min_lon").text 48 | val maxLat = (changeSetElement \ "@max_lat").text 49 | val maxLon = (changeSetElement \ "@max_lon").text 50 | 51 | var mapRouletteTag = false 52 | val tags = (changeSetElement \\ "tag").map { t => 53 | val key = (t \ "k").text 54 | val value = (t \ "v").text 55 | if (StringUtils.equals(key, "comment") && StringUtils.containsIgnoreCase(value, "maproulette")) { 56 | mapRouletteTag = true 57 | } 58 | key -> value 59 | }.toMap 60 | 61 | Changeset( 62 | (changeSetElement \ "@id").text.toLong, 63 | (changeSetElement \ "@user").text, 64 | (changeSetElement \ "@uid").text.toLong, 65 | DateTime.parse((changeSetElement \ "@created_at").text), 66 | DateTime.parse((changeSetElement \ "@closed_at").text), 67 | if (StringUtils.isEmpty(open)) { 68 | false 69 | } else { 70 | open.toBoolean 71 | }, 72 | if (StringUtils.isEmpty(minLat)) { 73 | 0D 74 | } else { 75 | minLat.toDouble 76 | }, 77 | if (StringUtils.isEmpty(minLon)) { 78 | 0D 79 | } else { 80 | minLon.toDouble 81 | }, 82 | if (StringUtils.isEmpty(maxLat)) { 83 | 0D 84 | } else { 85 | maxLat.toDouble 86 | }, 87 | if (StringUtils.isEmpty(maxLon)) { 88 | 0D 89 | } else { 90 | maxLon.toDouble 91 | }, 92 | (changeSetElement \ "@comments_count").text.toInt, 93 | tags, 94 | mapRouletteTag 95 | ) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /app/org/maproulette/provider/websockets/README.md: -------------------------------------------------------------------------------- 1 | # Websockets Overview 2 | 3 | Websockets provide a continuous connection between the client and the server 4 | that can be used to send individual messages back and forth at will without the 5 | need for constant polling by the client. 6 | 7 | At present, websocket messages are intended to be unidirectional from server to 8 | client for the purpose of messaging clients when specific events occur on the 9 | server (e.g. a new notification is delivered to a user, a task is approved by a 10 | reviewer, etc) so that clients need not poll for these events using the REST 11 | API. The only supported client-originated messages are "housekeeping" 12 | messages, such as subscribe and unsubscribe to control which types of messages 13 | a client receives, and a ping message that clients can can use to test that the 14 | websocket connection remains live. All of these client-generated messages are 15 | consumed and processed privately within this package and are not propagated on 16 | to other server code. Communication of data to the server still must occur via 17 | the REST API at this time. 18 | 19 | 20 | ## Class Overview 21 | 22 | `WebSocketActor`s are responsible for bi-directional communication with client 23 | websockets. A new instance is instantiated by the `WebSocketController` when a 24 | new websocket connection is established, and that WebSocketActor instance 25 | manages communication with that specific websocket connection. 26 | 27 | When a WebSocketActor instance receives a message from the client websocket, it 28 | consumes and processes the message as needed, including interacting with an 29 | Akka mediator to subscribe or unsubscribe itself to message subscription types 30 | on behalf of the client. When it receives a message from the server, it simply 31 | transmits a JSON representation of that message to the client websocket. 32 | 33 | `WebSocketMessages` defines case classes representing the various types of 34 | messages and payload data that can be transmitted via websocket, along with 35 | helper methods for easily and properly constructing those messages. 36 | 37 | An [Akka Mediator](https://doc.akka.io/docs/akka/current/distributed-pub-sub.html) 38 | is used to manage publish/subscribe of the various message subscription types. 39 | When a client sends a subscribe or unsubscribe message, its WebSocketActor will 40 | process the message and subscribe or unsubscribe itself with the mediator for 41 | the requested message subscription type. When server code wishes to transmit a 42 | message to clients, the WebSocketPublisher sends the message to the mediator, 43 | which then sends it to all subscribed WebSocketActors for transmission to their 44 | clients. 45 | 46 | `WebSocketPublisher` is an Akka actor responsible for communicating 47 | server-generated messages to the mediator for publication to subscribed 48 | clients. 49 | 50 | `WebSocketProvider` provides a convenient `sendMessage` function that server 51 | code can use to easily send a message via the WebSocketPublisher without having 52 | to interact directly or be aware of the Akka actor system. Most server code 53 | will wish to use this method to send messages rather than trying to deal with 54 | the Akka actor system. 55 | 56 | 57 | ## Adding a New Message Type 58 | 59 | 1. Edit WebSocketMessages and add: 60 | - case class for payload data 61 | - case class for message 62 | - helper method for generating message 63 | - writes for data class 64 | - writes for message class 65 | - new subscription type if needed 66 | 67 | 2. Edit WebSocketActor and add: 68 | - match case for the new message type 69 | -------------------------------------------------------------------------------- /app/org/maproulette/controllers/api/TagController.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.controllers.api 4 | 5 | import javax.inject.Inject 6 | import org.maproulette.controllers.CRUDController 7 | import org.maproulette.data.{ActionManager, TagType} 8 | import org.maproulette.models.Tag 9 | import org.maproulette.models.dal.TagDAL 10 | import org.maproulette.session.{SessionManager, User} 11 | import org.maproulette.utils.Utils 12 | import play.api.libs.json._ 13 | import play.api.mvc._ 14 | 15 | /** 16 | * The Tag controller handles all operations for the Challenge objects. 17 | * This includes CRUD operations and searching/listing. 18 | * See CRUDController for more details on CRUD object operations 19 | * 20 | * @author cuthbertm 21 | */ 22 | class TagController @Inject()(override val sessionManager: SessionManager, 23 | override val actionManager: ActionManager, 24 | override val dal: TagDAL, 25 | components: ControllerComponents, 26 | override val bodyParsers: PlayBodyParsers) 27 | extends AbstractController(components) with CRUDController[Tag] { 28 | 29 | // json reads for automatically reading Tags from a posted json body 30 | override implicit val tReads: Reads[Tag] = Tag.tagReads 31 | // json writes for automatically writing Tags to a json body response 32 | override implicit val tWrites: Writes[Tag] = Tag.tagWrites 33 | // The type of object that this controller deals with. 34 | override implicit val itemType = TagType() 35 | 36 | /** 37 | * Gets the tags based on a prefix. So if you are looking for all tags that begin with 38 | * "road_", then set the prefix to "road_" 39 | * 40 | * @param prefix The prefix for the tags 41 | * @param limit The limit on how many tags to be returned 42 | * @param offset This is used for page offsets, so if you limit 10 tags and have offset 0, then 43 | * changing to offset 1 will return the next set of 10 tags. 44 | * @return 45 | */ 46 | def getTags(prefix: String, tagType: String, limit: Int, offset: Int): Action[AnyContent] = Action.async { implicit request => 47 | this.sessionManager.userAwareRequest { implicit user => 48 | Ok(Json.toJson(this.dal.findTags(prefix, tagType, limit, offset))) 49 | } 50 | } 51 | 52 | /** 53 | * Function is primarily called from CRUDController, which is used to handle the actual creation 54 | * of the tags. The function it overrides does it in a very generic way, so this function is 55 | * specifically written so that it will update the tags correctly. Specifically tags have to be 56 | * matched on ids and names, instead of just ids. 57 | * 58 | * @param requestBody This is the posted request body in json format. 59 | * @param arr The list of Tag objects supplied in the json array from the request body 60 | * @param user The id of the user that is executing the request 61 | * @param update If an item is found then update it, if parameter set to true, otherwise we skip. 62 | */ 63 | override def internalBatchUpload(requestBody: JsValue, arr: List[JsValue], user: User, update: Boolean): Unit = { 64 | val tagList = arr.flatMap(element => (element \ "id").asOpt[Long] match { 65 | case Some(itemID) if update => element.validate[Tag].fold( 66 | errors => None, 67 | value => Some(value) 68 | ) 69 | case None => Utils.insertJsonID(element).validate[Tag].fold( 70 | errors => None, 71 | value => Some(value) 72 | ) 73 | }) 74 | this.dal.updateTagList(tagList, user) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/org/maproulette/models/dal/VirtualProjectDAL.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models.dal 4 | 5 | import java.sql.Connection 6 | 7 | import anorm._ 8 | import javax.inject.{Inject, Singleton} 9 | import org.maproulette.Config 10 | import org.maproulette.data.ProjectType 11 | import org.maproulette.exception.{InvalidException, NotFoundException} 12 | import org.maproulette.models._ 13 | import org.maproulette.permissions.Permission 14 | import org.maproulette.session.User 15 | import org.maproulette.session.dal.UserGroupDAL 16 | import org.postgresql.util.PSQLException 17 | import play.api.db.Database 18 | 19 | /** 20 | * Specific functions for virtual projects 21 | * 22 | * @author krotstan 23 | */ 24 | @Singleton 25 | class VirtualProjectDAL @Inject()(override val db: Database, 26 | childDAL: ChallengeDAL, 27 | surveyDAL: SurveyDAL, 28 | userGroupDAL: UserGroupDAL, 29 | override val permission: Permission, 30 | config: Config) 31 | extends ProjectDAL(db, childDAL, surveyDAL, userGroupDAL, permission, config) { 32 | 33 | /** 34 | * Adds a challenge to a virtual project. You are required to have write access 35 | * to the project you are adding the challenge to 36 | * 37 | * @param projectId The id of the virtual parent project 38 | * @param challengeId The id of the challenge that you are moving 39 | * @param c an implicit connection 40 | */ 41 | def addChallenge(projectId: Long, challengeId: Long, user: User)(implicit c: Option[Connection] = None): Option[Project] = { 42 | this.permission.hasWriteAccess(ProjectType(), user)(projectId) 43 | this.retrieveById(projectId) match { 44 | case Some(p) => if (!p.isVirtual.getOrElse(false)) { 45 | throw new InvalidException(s"Project must be a virtual project to add a challenge.") 46 | } 47 | case None => throw new NotFoundException(s"No project found with id $projectId found.") 48 | } 49 | 50 | this.withMRTransaction { implicit c => 51 | try { 52 | val query = 53 | s"""INSERT INTO virtual_project_challenges (project_id, challenge_id) 54 | VALUES ($projectId, $challengeId)""" 55 | SQL(query).execute() 56 | } catch { 57 | case e:PSQLException if (e.getSQLState == "23505") => //ignore 58 | case other:Throwable => 59 | throw new InvalidException( 60 | s"Unable to add challenge ${challengeId} to Virtual Project ${projectId}. " + 61 | other.getMessage) 62 | } 63 | None 64 | } 65 | } 66 | 67 | /** 68 | * Removes a challenge from a virtual project. You are required to have write access 69 | * to the project you are removing the challenge from. 70 | * 71 | * @param projectId The id of the virtual parent project 72 | * @param challengeId The id of the challenge that you are moving 73 | * @param c an implicit connection 74 | */ 75 | def removeChallenge(projectId: Long, challengeId: Long, user: User)(implicit c: Option[Connection] = None): Option[Project] = { 76 | this.permission.hasWriteAccess(ProjectType(), user)(projectId) 77 | this.retrieveById(projectId) match { 78 | case Some(p) => if (!p.isVirtual.getOrElse(false)) { 79 | throw new InvalidException(s"Project must be a virtual project to remove a challenge.") 80 | } 81 | case None => throw new NotFoundException(s"No challenge with id $challengeId found.") 82 | } 83 | this.withMRTransaction { implicit c => 84 | val query = 85 | s"""DELETE FROM virtual_project_challenges 86 | WHERE project_id=$projectId AND challenge_id=$challengeId""" 87 | SQL(query).execute() 88 | None 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /app/org/maproulette/models/utils/ChallengeFormatters.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models.utils 4 | 5 | import org.joda.time.DateTime 6 | import org.maproulette.models._ 7 | import org.maproulette.utils.Utils.{jsonReads, jsonWrites} 8 | import play.api.libs.functional.syntax._ 9 | import play.api.libs.json.JodaReads._ 10 | import play.api.libs.json.JodaWrites._ 11 | import play.api.libs.json._ 12 | 13 | /** 14 | * @author cuthbertm 15 | */ 16 | trait ChallengeWrites { 17 | implicit val challengeGeneralWrites: Writes[ChallengeGeneral] = Json.writes[ChallengeGeneral] 18 | implicit val challengeCreationWrites: Writes[ChallengeCreation] = Json.writes[ChallengeCreation] 19 | 20 | implicit object challengePriorityWrites extends Writes[ChallengePriority] { 21 | override def writes(challengePriority: ChallengePriority): JsValue = 22 | JsObject(Seq( 23 | "defaultPriority" -> JsNumber(challengePriority.defaultPriority), 24 | "highPriorityRule" -> Json.parse(challengePriority.highPriorityRule.getOrElse("{}")), 25 | "mediumPriorityRule" -> Json.parse(challengePriority.mediumPriorityRule.getOrElse("{}")), 26 | "lowPriorityRule" -> Json.parse(challengePriority.lowPriorityRule.getOrElse("{}")) 27 | )) 28 | } 29 | 30 | implicit val challengeExtraWrites: Writes[ChallengeExtra] = Json.writes[ChallengeExtra] 31 | 32 | implicit val challengeWrites: Writes[Challenge] = ( 33 | (JsPath \ "id").write[Long] and 34 | (JsPath \ "name").write[String] and 35 | (JsPath \ "created").write[DateTime] and 36 | (JsPath \ "modified").write[DateTime] and 37 | (JsPath \ "description").writeNullable[String] and 38 | (JsPath \ "deleted").write[Boolean] and 39 | (JsPath \ "infoLink").writeNullable[String] and 40 | JsPath.write[ChallengeGeneral] and 41 | JsPath.write[ChallengeCreation] and 42 | JsPath.write[ChallengePriority] and 43 | JsPath.write[ChallengeExtra] and 44 | (JsPath \ "status").writeNullable[Int] and 45 | (JsPath \ "statusMessage").writeNullable[String] and 46 | (JsPath \ "lastTaskRefresh").writeNullable[DateTime] and 47 | (JsPath \ "dataOriginDate").writeNullable[DateTime] and 48 | (JsPath \ "location").writeNullable[String](new jsonWrites("location")) and 49 | (JsPath \ "bounding").writeNullable[String](new jsonWrites("bounding")) 50 | ) (unlift(Challenge.unapply)) 51 | } 52 | 53 | trait ChallengeReads extends DefaultReads { 54 | implicit val challengeGeneralReads: Reads[ChallengeGeneral] = Json.reads[ChallengeGeneral] 55 | implicit val challengeCreationReads: Reads[ChallengeCreation] = Json.reads[ChallengeCreation] 56 | implicit val challengePriorityReads: Reads[ChallengePriority] = Json.reads[ChallengePriority] 57 | implicit val challengeExtraReads: Reads[ChallengeExtra] = Json.reads[ChallengeExtra] 58 | 59 | implicit val challengeReads: Reads[Challenge] = ( 60 | (JsPath \ "id").read[Long] and 61 | (JsPath \ "name").read[String] and 62 | ((JsPath \ "created").read[DateTime] or Reads.pure(DateTime.now())) and 63 | ((JsPath \ "modified").read[DateTime] or Reads.pure(DateTime.now())) and 64 | (JsPath \ "description").readNullable[String] and 65 | (JsPath \ "deleted").read[Boolean] and 66 | (JsPath \ "infoLink").readNullable[String] and 67 | JsPath.read[ChallengeGeneral] and 68 | JsPath.read[ChallengeCreation] and 69 | JsPath.read[ChallengePriority] and 70 | JsPath.read[ChallengeExtra] and 71 | (JsPath \ "status").readNullable[Int] and 72 | (JsPath \ "statusMessage").readNullable[String] and 73 | (JsPath \ "lastTaskRefresh").readNullable[DateTime] and 74 | (JsPath \ "dataOriginDate").readNullable[DateTime] and 75 | (JsPath \ "location").readNullable[String](new jsonReads("location")) and 76 | (JsPath \ "bounding").readNullable[String](new jsonReads("bounding")) 77 | ) (Challenge.apply _) 78 | } 79 | -------------------------------------------------------------------------------- /app/org/maproulette/provider/websockets/WebSocketActor.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.provider.websockets 4 | 5 | import play.api.libs.json._ 6 | import akka.actor._ 7 | import akka.cluster.pubsub.DistributedPubSub 8 | import akka.cluster.pubsub.DistributedPubSubMediator.{Subscribe, Unsubscribe} 9 | import org.maproulette.models.{Task, TaskWithReview, TaskReview, Challenge} 10 | 11 | /** 12 | * WebSocketActors are responsible for bi-directional communication with client 13 | * websockets. A new instance is instantiated by the WebSocketController when a 14 | * new websocket connection is established, and that WebSocketActor instance 15 | * manages communication with that websocket. 16 | * 17 | * When a WebSocketActor instance receives a message from the client websocket, 18 | * it performs initial processing of the message. When it receives a message 19 | * from the server, it simply transmits a JSON representation of that message 20 | * to the client websocket. 21 | * 22 | * Messages from the server will typically come by way of an Akka mediator that 23 | * manages the publish/subscribe of the various message types. The client can 24 | * send subscribe and unsubscribe messages to determine which message types it 25 | * is currently interested in receiving (e.g. task-review messages), and this 26 | * class will inform the mediator that it then wishes to subscribe or 27 | * unsubscribe to/from those types of messages. 28 | * 29 | * Note that this class is not responsible for sending server-initiated 30 | * messages to the mediator for distribution to clients -- the 31 | * WebSocketPublisher does that, though server code should use the 32 | * WebSocketProvider.sendMessage method rather than trying to access the 33 | * WebSocketPublisher actor directly. 34 | * 35 | * @author nrotstan 36 | */ 37 | class WebSocketActor(out: ActorRef) extends Actor { 38 | implicit val pingMessageReads = WebSocketMessages.pingMessageReads 39 | implicit val subscriptionDataReads = WebSocketMessages.subscriptionDataReads 40 | implicit val serverMetaWrites = WebSocketMessages.serverMetaWrites 41 | implicit val pongMessageWrites = WebSocketMessages.pongMessageWrites 42 | implicit val notificationDataWrites = WebSocketMessages.notificationDataWrites 43 | implicit val notificationMessageWrites = WebSocketMessages.notificationMessageWrites 44 | implicit val reviewDataWrites = WebSocketMessages.reviewDataWrites 45 | implicit val reviewMessageWrites = WebSocketMessages.reviewMessageWrites 46 | implicit val taskWithReviewWrites = TaskWithReview.taskWithReviewWrites 47 | implicit val taskReviewWrites = TaskReview.reviewWrites 48 | implicit val taskWrites: Writes[Task] = Task.TaskFormat 49 | implicit val challengeWrites: Writes[Challenge] = Challenge.writes.challengeWrites 50 | 51 | def receive = { 52 | case serverMessage: WebSocketMessages.NotificationMessage => 53 | out ! Json.toJson(serverMessage) 54 | case serverMessage: WebSocketMessages.ReviewMessage => 55 | out ! Json.toJson(serverMessage) 56 | case serverMessage: WebSocketMessages.TaskMessage => 57 | out ! Json.toJson(serverMessage) 58 | case clientMessage: WebSocketMessages.ClientMessage => 59 | clientMessage.messageType match { 60 | case "subscribe" => 61 | val mediator = DistributedPubSub(context.system).mediator 62 | val subscriptionData = clientMessage.data.get.as[WebSocketMessages.SubscriptionData] 63 | mediator ! Subscribe(subscriptionData.subscriptionName, self) 64 | case "unsubscribe" => 65 | val mediator = DistributedPubSub(context.system).mediator 66 | val subscriptionData = clientMessage.data.get.as[WebSocketMessages.SubscriptionData] 67 | mediator ! Unsubscribe(subscriptionData.subscriptionName, self) 68 | case "ping" => 69 | // Immediately send back pong 70 | out ! Json.toJson(WebSocketMessages.pong()) 71 | case _ => None 72 | } 73 | } 74 | } 75 | 76 | object WebSocketActor { 77 | def props(out: ActorRef) = Props(new WebSocketActor(out)) 78 | } 79 | -------------------------------------------------------------------------------- /app/org/maproulette/controllers/OSMChangesetController.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.controllers 4 | 5 | import javax.inject.{Inject, Singleton} 6 | import org.maproulette.exception.{StatusMessage, StatusMessages} 7 | import org.maproulette.services.osm._ 8 | import org.maproulette.session.SessionManager 9 | import play.api.libs.json.{JsError, JsValue, Json} 10 | import play.api.mvc._ 11 | 12 | import scala.concurrent.{Future, Promise} 13 | import scala.util.{Failure, Success} 14 | import scala.xml.Elem 15 | 16 | /** 17 | * @author mcuthbert 18 | */ 19 | @Singleton 20 | class OSMChangesetController @Inject()(components: ControllerComponents, 21 | sessionManager: SessionManager, 22 | changeService: ChangesetProvider, 23 | bodyParsers: PlayBodyParsers) extends AbstractController(components) with StatusMessages { 24 | 25 | import scala.concurrent.ExecutionContext.Implicits.global 26 | 27 | implicit val tagChangeReads = ChangeObjects.tagChangeReads 28 | implicit val tagChangeResultWrites = ChangeObjects.tagChangeResultWrites 29 | implicit val tagChangeSubmissionReads = ChangeObjects.tagChangeSubmissionReads 30 | 31 | /** 32 | * Returns the changes requested by the user without submitting it to OSM. This will be a json 33 | * format that will contain before and after results for the requested tag changes 34 | * 35 | * @return 36 | */ 37 | def testTagChange(changeType: String) : Action[JsValue] = Action.async(bodyParsers.json) { implicit request => 38 | this.sessionManager.authenticatedFutureRequest { implicit user => 39 | val result = request.body.validate[List[TagChange]] 40 | result.fold( 41 | errors => { 42 | Future { 43 | BadRequest(Json.toJson(StatusMessage("KO", JsError.toJson(errors)))) 44 | } 45 | }, 46 | element => { 47 | val p = Promise[Result] 48 | val future = changeType match { 49 | case OSMChangesetController.CHANGETYPE_OSMCHANGE => changeService.getOsmChange(element) 50 | case _ => changeService.testTagChange(element) 51 | } 52 | future onComplete { 53 | case Success(res) => 54 | changeType match { 55 | case OSMChangesetController.CHANGETYPE_OSMCHANGE => p success Ok(res.asInstanceOf[Elem]).as("text/xml") 56 | case _ => p success Ok(Json.toJson(res.asInstanceOf[List[TagChangeResult]])) 57 | } 58 | case Failure(f) => p failure f 59 | } 60 | p.future 61 | } 62 | ) 63 | } 64 | } 65 | 66 | /** 67 | * Submits a tag change to the open street map servers to be applied to the data. 68 | * 69 | * @return 70 | */ 71 | def submitTagChange() : Action[JsValue] = Action.async(bodyParsers.json) { implicit request => 72 | this.sessionManager.authenticatedFutureRequest { implicit user => 73 | val result = request.body.validate[TagChangeSubmission] 74 | result.fold( 75 | errors => { 76 | Future { 77 | BadRequest(Json.toJson(StatusMessage("KO", JsError.toJson(errors)))) 78 | } 79 | }, 80 | element => { 81 | val p = Promise[Result] 82 | changeService.submitTagChange(element.changes, element.comment, user.osmProfile.requestToken) onComplete { 83 | case Success(res) => p success Ok(res) 84 | case Failure(f) => p failure f 85 | } 86 | p.future 87 | } 88 | ) 89 | } 90 | } 91 | 92 | // todo create API that will allow user to submit an OSMChange XML directly into the service. The biggest potential 93 | // downside to this approach, is that there is little control over what is submitted. 94 | } 95 | 96 | object OSMChangesetController { 97 | val CHANGETYPE_OSMCHANGE = "osmchange" 98 | val CHANGETYPE_DELTA = "delta" 99 | } 100 | -------------------------------------------------------------------------------- /app/org/maproulette/controllers/api/NotificationController.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.controllers.api 4 | 5 | import javax.inject.Inject 6 | import org.apache.commons.lang3.StringUtils 7 | import org.maproulette.exception.{InvalidException, NotFoundException, StatusMessage} 8 | import org.maproulette.models.dal.{NotificationDAL} 9 | import org.maproulette.session.{SessionManager, User} 10 | import org.maproulette.models.{NotificationSubscriptions} 11 | import org.maproulette.utils.{Crypto, Utils} 12 | import play.api.libs.json._ 13 | import play.api.mvc._ 14 | 15 | import scala.concurrent.Promise 16 | import scala.util.{Failure, Success} 17 | 18 | /** 19 | * @author nrotstan 20 | */ 21 | class NotificationController @Inject()(notificationDAL: NotificationDAL, 22 | sessionManager: SessionManager, 23 | components: ControllerComponents, 24 | bodyParsers: PlayBodyParsers, 25 | crypto: Crypto) extends AbstractController(components) with DefaultWrites { 26 | 27 | import scala.concurrent.ExecutionContext.Implicits.global 28 | 29 | implicit val notificationSubscriptionReads = NotificationSubscriptions.notificationSubscriptionReads 30 | implicit val notificationSubscriptionWrites = NotificationSubscriptions.notificationSubscriptionWrites 31 | 32 | def getUserNotifications(userId: Long, limit: Int, page: Int, sort: String, order: String, 33 | notificationType: Option[Int]=None, isRead: Option[Int]=None, 34 | fromUsername: Option[String]=None, challengeId: Option[Long]=None): Action[AnyContent] = Action.async { implicit request => 35 | this.sessionManager.authenticatedRequest { implicit user => 36 | // Routes don't allow Boolean options, so convert isRead from Int option (zero false, non-zero true) 37 | val readFilter:Option[Boolean] = isRead match { 38 | case Some(value) => Some(value != 0) 39 | case None => None 40 | } 41 | 42 | Ok(Json.toJson(this.notificationDAL.getUserNotifications(userId, user, limit, page * limit, sort, order, 43 | notificationType, readFilter, fromUsername, challengeId))) 44 | } 45 | } 46 | 47 | def markNotificationsRead(userId: Long, notificationIds: String): Action[AnyContent] = Action.async { implicit request => 48 | this.sessionManager.authenticatedRequest { implicit user => 49 | if (!StringUtils.isEmpty(notificationIds)) { 50 | val parsedNotificationIds = Utils.split(notificationIds).map(_.toLong) 51 | this.notificationDAL.markNotificationsRead(userId, user, parsedNotificationIds) 52 | } 53 | Ok(Json.toJson(StatusMessage("OK", JsString(s"Notifications marked as read")))) 54 | } 55 | } 56 | 57 | def deleteNotifications(userId: Long, notificationIds: String): Action[AnyContent] = Action.async { implicit request => 58 | this.sessionManager.authenticatedRequest { implicit user => 59 | if (!StringUtils.isEmpty(notificationIds)) { 60 | val parsedNotificationIds = Utils.split(notificationIds).map(_.toLong) 61 | this.notificationDAL.deleteNotifications(userId, user, parsedNotificationIds) 62 | } 63 | Ok(Json.toJson(StatusMessage("OK", JsString(s"Notifications deleted")))) 64 | } 65 | } 66 | 67 | def getNotificationSubscriptions(userId: Long): Action[AnyContent] = Action.async { implicit request => 68 | this.sessionManager.authenticatedRequest { implicit user => 69 | Ok(Json.toJson(this.notificationDAL.getNotificationSubscriptions(userId, user))) 70 | } 71 | } 72 | 73 | def updateNotificationSubscriptions(userId: Long): Action[JsValue] = Action.async(bodyParsers.json) { implicit request => 74 | this.sessionManager.authenticatedRequest { implicit user => 75 | notificationDAL.updateNotificationSubscriptions(userId, user, request.body.as[NotificationSubscriptions]) 76 | Ok(Json.toJson(StatusMessage("OK", JsString(s"Subscriptions updated")))) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/org/maproulette/exception/MPExceptionUtil.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.exception 4 | 5 | import org.slf4j.LoggerFactory 6 | import play.api.Logger 7 | import play.api.libs.json.{JsString, Json} 8 | import play.api.mvc.Result 9 | import play.api.mvc.Results._ 10 | import play.shaded.oauth.oauth.signpost.exception.OAuthNotAuthorizedException 11 | 12 | import scala.concurrent.ExecutionContext.Implicits.global 13 | import scala.concurrent.{Future, Promise} 14 | import scala.util.{Failure, Success, Try} 15 | 16 | /** 17 | * Function wrappers that wrap our code blocks in try catches. 18 | * 19 | * @author cuthbertm 20 | */ 21 | object MPExceptionUtil { 22 | 23 | import play.api.http.Status._ 24 | 25 | private val logger = LoggerFactory.getLogger(this.getClass) 26 | 27 | /** 28 | * Used for Actions, wraps the code block and if InvalidException found will send a BadRequest, 29 | * all other exceptions sent back as InternalServerError 30 | * 31 | * @param block The block of code to be executed expecting a Result back 32 | * @return Result 33 | */ 34 | def internalExceptionCatcher(block: () => Result): Result = { 35 | try { 36 | block() 37 | } catch { 38 | case e: InvalidException => 39 | logger.error(e.getMessage, e) 40 | BadRequest(Json.toJson(StatusMessage("KO", JsString(e.getMessage)))) 41 | case e: IllegalAccessException => 42 | logger.error(e.getMessage) 43 | Forbidden(Json.toJson(StatusMessage("Forbidden", JsString(e.getMessage)))) 44 | case e: NotFoundException => 45 | logger.error(e.getMessage, e) 46 | NotFound(Json.toJson(StatusMessage("NotFound", JsString(e.getMessage)))) 47 | case e: Exception => 48 | logger.error(e.getMessage, e) 49 | InternalServerError(Json.toJson(StatusMessage("KO", JsString(e.getMessage)))) 50 | } 51 | } 52 | 53 | /** 54 | * Used for async Actions, so the expected result from the block of code is a Future, 55 | * on success will simply pass the result on, on failure will throw either a BadRequest, 56 | * Unauthorized or InternalServerError 57 | * 58 | * @param block The block of code to be executed expecting a Future[Result] back 59 | * @return Future[Result] 60 | */ 61 | def internalAsyncExceptionCatcher(block: () => Future[Result]): Future[Result] = { 62 | val p = Promise[Result] 63 | Try(block()) match { 64 | case Success(f) => f onComplete { 65 | case Success(result) => p success result 66 | case Failure(e) => p success manageException(e) 67 | } 68 | case Failure(e) => p success manageException(e) 69 | } 70 | p.future 71 | } 72 | 73 | def manageException(e: Throwable): Result = { 74 | e match { 75 | case e: InvalidException => 76 | logger.error(e.getMessage, e) 77 | BadRequest(Json.toJson(StatusMessage("KO", JsString(e.getMessage)))) 78 | case e: OAuthNotAuthorizedException => 79 | logger.error(e.getMessage) 80 | Unauthorized(Json.toJson(StatusMessage("NotAuthorized", JsString(e.getMessage)))).withNewSession 81 | case e: IllegalAccessException => 82 | logger.error(e.getMessage, e) 83 | Forbidden(Json.toJson(StatusMessage("Forbidden", JsString(e.getMessage)))) 84 | case e: NotFoundException => 85 | logger.error(e.getMessage, e) 86 | NotFound(Json.toJson(StatusMessage("NotFound", JsString(e.getMessage)))) 87 | case e: ChangeConflictException => 88 | logger.error(e.getMessage, e) 89 | Conflict(Json.toJson(StatusMessage("Conflict", JsString(e.getMessage)))) 90 | case e: Throwable => 91 | logger.error(e.getMessage, e) 92 | InternalServerError(Json.toJson(StatusMessage("KO", JsString(e.getMessage)))) 93 | } 94 | } 95 | 96 | private def manageUIException(e: Throwable): Result = { 97 | logger.debug(e.getMessage, e) 98 | Redirect(s"/mr3/error", Map("errormsg" -> Seq(e.getMessage)), PERMANENT_REDIRECT).withHeaders(("Cache-Control", "no-cache")) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /app/org/maproulette/controllers/api/TaskHistoryController.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.controllers.api 4 | 5 | import javax.inject.Inject 6 | import org.maproulette.Config 7 | import org.maproulette.data.ActionManager 8 | import org.maproulette.models.dal._ 9 | import org.maproulette.models.{Answer, Challenge, TaskLogEntry} 10 | import org.maproulette.permissions.Permission 11 | import org.maproulette.session.{SessionManager, User} 12 | import org.maproulette.utils.Utils 13 | import org.maproulette.services.osm.ChangesetProvider 14 | import org.maproulette.provider.websockets.{WebSocketMessages, WebSocketProvider} 15 | import play.api.libs.json._ 16 | import play.api.libs.ws.WSClient 17 | import play.api.mvc._ 18 | 19 | /** 20 | * TaskHistoryController is responsible for fetching the history of a task. 21 | * 22 | * @author krotstan 23 | */ 24 | class TaskHistoryController @Inject()(override val sessionManager: SessionManager, 25 | override val actionManager: ActionManager, 26 | override val dal: TaskDAL, 27 | override val tagDAL: TagDAL, 28 | taskHistoryDAL: TaskHistoryDAL, 29 | dalManager: DALManager, 30 | wsClient: WSClient, 31 | webSocketProvider: WebSocketProvider, 32 | config: Config, 33 | components: ControllerComponents, 34 | changeService: ChangesetProvider, 35 | override val bodyParsers: PlayBodyParsers) 36 | extends TaskController(sessionManager, actionManager, dal, tagDAL, dalManager, 37 | wsClient, webSocketProvider, config, components, changeService, bodyParsers) { 38 | 39 | /** 40 | * Gets the history for a task. This includes commments, status_actions, and review_actions. 41 | * 42 | * @param taskId The id of the task being viewed 43 | * @return 44 | */ 45 | def getTaskHistoryLog(taskId: Long): Action[AnyContent] = Action.async { implicit request => 46 | this.sessionManager.authenticatedRequest { implicit user => 47 | Ok(_insertExtraJSON(this.taskHistoryDAL.getTaskHistoryLog(taskId))) 48 | } 49 | } 50 | 51 | /** 52 | * Fetches and inserts usernames for 'osm_user_id', 'reviewRequestedBy' and 'reviewBy' 53 | */ 54 | private def _insertExtraJSON(entries: List[TaskLogEntry]): JsValue = { 55 | if (entries.isEmpty) { 56 | Json.toJson(List[JsValue]()) 57 | } else { 58 | val users = Some(this.dalManager.user.retrieveListById(-1, 0)(entries.map( 59 | t => t.user.getOrElse(0).toLong)).map(u => 60 | u.id -> Json.obj("username" -> u.name, "id" -> u.id)).toMap) 61 | 62 | val mappers = Some(this.dalManager.user.retrieveListById(-1, 0)(entries.map( 63 | t => t.reviewRequestedBy.getOrElse(0).toLong)).map(u => 64 | u.id -> Json.obj("username" -> u.name, "id" -> u.id)).toMap) 65 | 66 | val reviewers = Some(this.dalManager.user.retrieveListById(-1, 0)(entries.map( 67 | t => t.reviewedBy.getOrElse(0).toLong)).map(u => 68 | u.id -> Json.obj("username" -> u.name, "id" -> u.id)).toMap) 69 | 70 | val jsonList = entries.map { entry => 71 | var updated = Json.toJson(entry) 72 | if (entry.user.getOrElse(0) != 0) { 73 | val usersJson = Json.toJson(users.get(entry.user.get.toLong)).as[JsObject] 74 | updated = Utils.insertIntoJson(updated, "user", usersJson, true) 75 | } 76 | if (entry.reviewRequestedBy.getOrElse(0) != 0) { 77 | val mapperJson = Json.toJson(mappers.get(entry.reviewRequestedBy.get.toLong)).as[JsObject] 78 | updated = Utils.insertIntoJson(updated, "reviewRequestedBy", mapperJson, true) 79 | } 80 | if (entry.reviewedBy.getOrElse(0) != 0) { 81 | val reviewerJson = Json.toJson(reviewers.get(entry.reviewedBy.get.toLong)).as[JsObject] 82 | updated = Utils.insertIntoJson(updated, "reviewedBy", reviewerJson, true) 83 | } 84 | 85 | updated 86 | } 87 | Json.toJson(jsonList) 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /app/org/maproulette/models/UserNotification.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2016 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models 4 | 5 | import org.joda.time.DateTime 6 | import play.api.libs.json.{DefaultWrites, Json, Reads, Writes} 7 | import play.api.libs.json.JodaWrites._ 8 | import play.api.libs.json.JodaReads._ 9 | 10 | /** 11 | * A user notification represents communication to a user about an event. It 12 | * can be associated with a task/challenge/project as well as other entities, 13 | * such as comments or reviews (referenced by targetId). 14 | * 15 | * @author nrotstan 16 | */ 17 | case class UserNotification(var id: Long, 18 | var userId: Long, 19 | var notificationType: Int, 20 | var created: DateTime=DateTime.now, 21 | var modified: DateTime=DateTime.now, 22 | var description: Option[String]=None, 23 | var fromUsername: Option[String]=None, 24 | var challengeName: Option[String]=None, 25 | var isRead: Boolean=false, 26 | var emailStatus: Int=0, 27 | var taskId: Option[Long]=None, 28 | var challengeId: Option[Long]=None, 29 | var projectId: Option[Long]=None, 30 | var targetId: Option[Long]=None, 31 | var extra: Option[String]=None) 32 | 33 | object UserNotification { 34 | implicit val notificationWrites: Writes[UserNotification] = Json.writes[UserNotification] 35 | implicit val notificationReads: Reads[UserNotification] = Json.reads[UserNotification] 36 | 37 | val NOTIFICATION_TYPE_SYSTEM = 0 38 | val NOTIFICATION_TYPE_SYSTEM_NAME = "System Message" 39 | val NOTIFICATION_TYPE_MENTION = 1 40 | val NOTIFICATION_TYPE_MENTION_NAME = "Comment Mention" 41 | val NOTIFICATION_TYPE_REVIEW_APPROVED = 2 42 | val NOTIFICATION_TYPE_REVIEW_APPROVED_NAME = "Task Approved" 43 | val NOTIFICATION_TYPE_REVIEW_REJECTED = 3 44 | val NOTIFICATION_TYPE_REVIEW_REJECTED_NAME = "Revision Requested" 45 | val NOTIFICATION_TYPE_REVIEW_AGAIN = 4 46 | val NOTIFICATION_TYPE_REVIEW_AGAIN_NAME = "Review Requested" 47 | val NOTIFICATION_TYPE_CHALLENGE_COMPLETED = 5 48 | val NOTIFICATION_TYPE_CHALLENGE_COMPLETED_NAME = "Challenge Completed" 49 | val notificationTypeMap = Map( 50 | NOTIFICATION_TYPE_SYSTEM -> NOTIFICATION_TYPE_SYSTEM_NAME, 51 | NOTIFICATION_TYPE_MENTION -> NOTIFICATION_TYPE_MENTION_NAME, 52 | NOTIFICATION_TYPE_REVIEW_APPROVED -> NOTIFICATION_TYPE_REVIEW_APPROVED_NAME, 53 | NOTIFICATION_TYPE_REVIEW_REJECTED -> NOTIFICATION_TYPE_REVIEW_REJECTED_NAME, 54 | NOTIFICATION_TYPE_REVIEW_AGAIN -> NOTIFICATION_TYPE_REVIEW_AGAIN_NAME, 55 | NOTIFICATION_TYPE_CHALLENGE_COMPLETED -> NOTIFICATION_TYPE_CHALLENGE_COMPLETED_NAME, 56 | ) 57 | 58 | val NOTIFICATION_IGNORE = 0 // ignore notification 59 | val NOTIFICATION_EMAIL_NONE = 1 // no email desired 60 | val NOTIFICATION_EMAIL_IMMEDIATE = 2 // send email immediately 61 | val NOTIFICATION_EMAIL_DIGEST = 3 // include in daily digest 62 | val NOTIFICATION_EMAIL_SENT = 4 // requested email sent 63 | } 64 | 65 | case class NotificationSubscriptions(val id: Long, 66 | val userId: Long, 67 | val system: Int, 68 | val mention: Int, 69 | val reviewApproved: Int, 70 | val reviewRejected: Int, 71 | val reviewAgain: Int, 72 | val challengeCompleted: Int) 73 | object NotificationSubscriptions { 74 | implicit val notificationSubscriptionReads: Reads[NotificationSubscriptions] = Json.reads[NotificationSubscriptions] 75 | implicit val notificationSubscriptionWrites: Writes[NotificationSubscriptions] = Json.writes[NotificationSubscriptions] 76 | } 77 | 78 | case class UserNotificationEmail(val id: Long, 79 | val userId: Long, 80 | val notificationType: Int, 81 | val created: DateTime, 82 | val emailStatus: Int) 83 | 84 | case class UserNotificationEmailDigest(val userId: Long, 85 | val notifications: List[UserNotificationEmail]) 86 | -------------------------------------------------------------------------------- /conf/evolutions/default/2.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Helper function to add/drop columns safely 5 | CREATE OR REPLACE FUNCTION add_drop_column(tablename varchar, colname varchar, coltype varchar, addcolumn boolean default true) RETURNS varchar AS $$ 6 | DECLARE 7 | col_name varchar;; 8 | BEGIN 9 | EXECUTE 'SELECT column_name FROM information_schema.columns WHERE table_name =' || quote_literal(tablename) || ' and column_name = ' || quote_literal(colname) into col_name;; 10 | RAISE INFO ' the val : % ', col_name;; 11 | IF (col_name IS NULL AND addcolumn) THEN 12 | EXECUTE 'ALTER TABLE IF EXISTS ' || tablename || ' ADD COLUMN ' || colname || ' ' || coltype;; 13 | ELSEIF (col_name IS NOT NULL AND NOT addcolumn) THEN 14 | EXECUTE 'ALTER TABLE IF EXISTS ' || tablename || ' DROP COLUMN ' || colname;; 15 | END IF;; 16 | RETURN col_name;; 17 | END 18 | $$ 19 | LANGUAGE plpgsql VOLATILE;; 20 | 21 | -- adding new column for task to set it in priority 1 (HIGH), 2 (MEDIUM) or 3 (LOW), defaults to 1 22 | SELECT add_drop_column('tasks', 'priority', 'integer DEFAULT 0'); 23 | -- enabled defaults to false now 24 | ALTER TABLE IF EXISTS projects ALTER COLUMN enabled SET DEFAULT false; 25 | ALTER TABLE IF EXISTS challenges ALTER COLUMN enabled SET DEFAULT false; 26 | -- New options for challenges 27 | SELECT add_drop_column('challenges', 'default_priority', 'integer DEFAULT 0'); 28 | SELECT add_drop_column('challenges', 'high_priority_rule', 'character varying'); 29 | SELECT add_drop_column('challenges', 'medium_priority_rule', 'character varying'); 30 | SELECT add_drop_column('challenges', 'low_priority_rule', 'character varying'); 31 | SELECT add_drop_column('challenges', 'extra_options', 'HSTORE'); 32 | 33 | -- Creates or updates and task. Will also check if task status needs to be updated 34 | -- This change simply adds the priority 35 | CREATE OR REPLACE FUNCTION create_update_task(task_name text, task_parent_id bigint, task_instruction text, task_status integer, task_id bigint DEFAULT -1, task_priority integer DEFAULT 0, reset_interval text DEFAULT '7 days') RETURNS integer as $$ 36 | DECLARE 37 | return_id integer;; 38 | BEGIN 39 | return_id := task_id;; 40 | IF (SELECT task_id) = -1 THEN 41 | BEGIN 42 | INSERT INTO tasks (name, parent_id, instruction, priority) VALUES (task_name, task_parent_id, task_instruction, task_priority) RETURNING id INTO return_id;; 43 | EXCEPTION WHEN UNIQUE_VIOLATION THEN 44 | SELECT INTO return_id update_task(task_name, task_parent_id, task_instruction, task_status, task_id, task_priority, reset_interval);; 45 | END;; 46 | ELSE 47 | PERFORM update_task(task_name, task_parent_id, task_instruction, task_status, task_id, task_priority, reset_interval);; 48 | END IF;; 49 | RETURN return_id;; 50 | END 51 | $$ 52 | LANGUAGE plpgsql VOLATILE;; 53 | 54 | CREATE OR REPLACE FUNCTION update_task(task_name text, task_parent_id bigint, task_instruction text, task_status integer, task_id bigint DEFAULT -1, task_priority integer DEFAULT 0, reset_interval text DEFAULT '7 days') RETURNS integer as $$ 55 | DECLARE 56 | update_id integer;; 57 | update_modified timestamp without time zone;; 58 | update_status integer;; 59 | new_status integer;; 60 | BEGIN 61 | IF (SELECT task_id) = -1 THEN 62 | SELECT id, modified, status INTO update_id, update_modified, update_status FROM tasks WHERE name = task_name AND parent_id = task_parent_id;; 63 | ELSE 64 | SELECT id, modified, status INTO update_id, update_modified, update_status FROM tasks WHERE id = task_id;; 65 | END IF;; 66 | new_status := task_status;; 67 | IF update_status = task_status AND (SELECT AGE(NOW(), update_modified)) > reset_interval::INTERVAL THEN 68 | new_status := 0;; 69 | END IF;; 70 | UPDATE tasks SET name = task_name, instruction = task_instruction, status = new_status, priority = task_priority WHERE id = update_id;; 71 | RETURN update_id;; 72 | END 73 | $$ 74 | LANGUAGE plpgsql VOLATILE;; 75 | 76 | # --- !Downs 77 | --SELECT add_drop_column('tasks', 'priority', '', false); 78 | --ALTER TABLE IF EXISTS projects ALTER COLUMN enabled SET DEFAULT true; 79 | --ALTER TABLE IF EXISTS challenges ALTER COLUMN enabled SET DEFAULT true; 80 | --SELECT add_drop_column('challenges', 'default_priority', '', false); 81 | --SELECT add_drop_column('challenges', 'high_priority_rule', '', false); 82 | --SELECT add_drop_column('challenges', 'medium_priority_rule', '', false); 83 | --SELECT add_drop_column('challenges', 'low_priority_rule', '', false); 84 | --SELECT add_drop_column('challenges', 'extra_options', '', false); 85 | --DROP FUNCTION IF EXISTS add_drop_column(tablename varchar, colname varchar, coltype varchar, addcolumn boolean default true); 86 | -------------------------------------------------------------------------------- /conf/evolutions/default/13.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | SELECT add_drop_column('users', 'properties', 'character varying');; 5 | SELECT create_index_if_not_exists('status_actions', 'osm_user_id_created', '(osm_user_id,created)');; 6 | -- Add changeset_id column 7 | SELECT add_drop_column('tasks', 'changeset_id', 'integer DEFAULT -1');; 8 | SELECT add_drop_column('challenges', 'info_link', 'character varying');; 9 | 10 | -- Add deleted columns for challenges and projects 11 | SELECT add_drop_column('challenges', 'deleted', 'boolean default false');; 12 | SELECT add_drop_column('projects', 'deleted', 'boolean default false');; 13 | 14 | -- Add trigger function that will set challenges to deleted if the project is deleted 15 | CREATE OR REPLACE FUNCTION on_project_delete_update() RETURNS TRIGGER AS $$ 16 | BEGIN 17 | IF new.deleted = true AND old.deleted = false THEN 18 | UPDATE challenges SET deleted = true WHERE parent_id = new.id;; 19 | ELSEIF new.deleted = false AND old.deleted = true THEN 20 | UPDATE challenges SET deleted = false WHERE parent_id = new.id;; 21 | END IF;; 22 | RETURN new;; 23 | END 24 | $$ 25 | LANGUAGE plpgsql VOLATILE;; 26 | 27 | DROP TRIGGER IF EXISTS on_project_update_delete ON projects;; 28 | CREATE TRIGGER on_project_update_delete AFTER UPDATE ON projects 29 | FOR EACH ROW EXECUTE PROCEDURE on_project_delete_update();; 30 | 31 | -- Creates or updates and task. Will also check if task status needs to be updated 32 | -- This change simply adds the priority 33 | CREATE OR REPLACE FUNCTION create_update_task(task_name text, 34 | task_parent_id bigint, 35 | task_instruction text, 36 | task_status integer, 37 | task_id bigint DEFAULT -1, 38 | task_priority integer DEFAULT 0, 39 | task_changeset_id bigint DEFAULT -1, 40 | reset_interval text DEFAULT '7 days') RETURNS integer as $$ 41 | DECLARE 42 | return_id integer;; 43 | BEGIN 44 | return_id := task_id;; 45 | IF (SELECT task_id) = -1 THEN 46 | BEGIN 47 | INSERT INTO tasks (name, parent_id, instruction, priority) VALUES (task_name, task_parent_id, task_instruction, task_priority) RETURNING id INTO return_id;; 48 | EXCEPTION WHEN UNIQUE_VIOLATION THEN 49 | SELECT INTO return_id update_task(task_name, task_parent_id, task_instruction, task_status, task_id, task_priority, task_changeset_id, reset_interval);; 50 | END;; 51 | ELSE 52 | PERFORM update_task(task_name, task_parent_id, task_instruction, task_status, task_id, task_priority, task_changeset_id, reset_interval);; 53 | END IF;; 54 | RETURN return_id;; 55 | END 56 | $$ 57 | LANGUAGE plpgsql VOLATILE;; 58 | 59 | CREATE OR REPLACE FUNCTION update_task(task_name text, 60 | task_parent_id bigint, 61 | task_instruction text, 62 | task_status integer, 63 | task_id bigint DEFAULT -1, 64 | task_priority integer DEFAULT 0, 65 | task_changeset_id bigint DEFAULT -1, 66 | reset_interval text DEFAULT '7 days') RETURNS integer as $$ 67 | DECLARE 68 | update_id integer;; 69 | update_modified timestamp without time zone;; 70 | update_status integer;; 71 | new_status integer;; 72 | BEGIN 73 | IF (SELECT task_id) = -1 THEN 74 | SELECT id, modified, status INTO update_id, update_modified, update_status FROM tasks WHERE name = task_name AND parent_id = task_parent_id;; 75 | ELSE 76 | SELECT id, modified, status INTO update_id, update_modified, update_status FROM tasks WHERE id = task_id;; 77 | END IF;; 78 | new_status := task_status;; 79 | IF update_status = task_status AND (SELECT AGE(NOW(), update_modified)) > reset_interval::INTERVAL THEN 80 | new_status := 0;; 81 | END IF;; 82 | UPDATE tasks SET name = task_name, instruction = task_instruction, status = new_status, priority = task_priority, changeset_id = task_changeset_id WHERE id = update_id;; 83 | RETURN update_id;; 84 | END 85 | $$ 86 | LANGUAGE plpgsql VOLATILE;; 87 | 88 | -- Add constraint that doesn't allow the same user to create a virtual challenge with the same name 89 | ALTER TABLE virtual_challenges DROP CONSTRAINT IF EXISTS CON_VIRTUAL_CHALLENGES_USER_ID_NAME;; 90 | ALTER TABLE virtual_challenges ADD CONSTRAINT CON_VIRTUAL_CHALLENGES_USER_ID_NAME 91 | UNIQUE (owner_id, name);; 92 | 93 | # --- !Downs 94 | -------------------------------------------------------------------------------- /app/org/maproulette/cache/Cache.scala: -------------------------------------------------------------------------------- 1 | package org.maproulette.cache 2 | 3 | import org.joda.time.{LocalDateTime, Seconds} 4 | 5 | /** 6 | * @author mcuthbert 7 | */ 8 | case class BasicInnerValue[Key, Value](key: Key, value: Value, accessTime: LocalDateTime, localExpiry: Option[Int] = None) 9 | 10 | trait Cache[Key, Value <: CacheObject[Key]] { 11 | 12 | implicit val cacheLimit:Int 13 | implicit val cacheExpiry:Int 14 | 15 | /** 16 | * Adds an object to the cache, if cache limit has been reached, then will remove the oldest 17 | * accessed item in the cache 18 | * 19 | * @param obj The object to add to the cache 20 | * @param localExpiry You can add a custom expiry to a specific element in seconds 21 | * @return The object put in the cache, or None if it could not be placed in the cache 22 | */ 23 | def addObject(obj: Value, localExpiry: Option[Int] = None): Option[Value] = synchronized { 24 | this.add(obj.id, obj, localExpiry) 25 | } 26 | 27 | /** 28 | * Checks if an item is cached or not 29 | * 30 | * @param key The id of the object to check to see if it is in the cache 31 | * @return true if the item is found in the cache 32 | */ 33 | def isCached(key: Key): Boolean 34 | 35 | /** 36 | * Fully clears the cache, this may not be applicable for non basic in memory caches 37 | */ 38 | def clear(): Unit 39 | 40 | /** 41 | * The current size of the cache 42 | * 43 | * @return 44 | */ 45 | def size: Int 46 | 47 | /** 48 | * True size is a little bit more accurate than size, however the performance will be a bit slower 49 | * as this size will loop through all the objects in the cache and expire out any items that have 50 | * expired. Thereby giving the true size at the end. 51 | * 52 | * @return 53 | */ 54 | def trueSize: Int 55 | 56 | /** 57 | * Adds an object to the cache, if cache limit has been reached, then will remove the oldest 58 | * accessed item in the cache 59 | * 60 | * @param key The object to add to the cache 61 | * @param value You can add a custom expiry to a specific element in seconds 62 | * @return The object put in the cache, or None if it could not be placed in the cache 63 | */ 64 | def add(key: Key, value: Value, localExpiry: Option[Int] = None): Option[Value] 65 | 66 | /** 67 | * Gets an object from the cache 68 | * 69 | * @param id The id of the object you are looking for 70 | * @return The object from the cache, None if not found 71 | */ 72 | def get(id: Key): Option[Value] = synchronized { 73 | this.innerGet(id) match { 74 | case Some(value) => 75 | if (isExpired(value)) { 76 | None 77 | } else { 78 | // because it has been touched, we need to update the accesstime 79 | add(id, value.value) 80 | Some(value.value) 81 | } 82 | case None => None 83 | } 84 | } 85 | 86 | /** 87 | * Finds an object from the cache based on the name instead of the id 88 | * 89 | * @param name The name of the object you wish to find 90 | * @return The object from the cache, None if not found 91 | */ 92 | def find(name: String): Option[Value] 93 | 94 | /** 95 | * Remove an object from the cache based on the name 96 | * 97 | * @param name The name of the object to be removed 98 | * @return The object removed from the cache, or None if it could not be removed from the cache, 99 | * or was not originally in the cache 100 | */ 101 | def remove(name: String): Option[Value] 102 | 103 | def remove(id: Key) : Option[Value] 104 | 105 | protected def innerGet(key: Key): Option[BasicInnerValue[Key, Value]] 106 | 107 | protected def isExpiredByKey(key: Key): Boolean = synchronized { 108 | this.innerGet(key) match { 109 | case Some(value) => isExpired(value) 110 | case None => true 111 | } 112 | } 113 | 114 | /** 115 | * Checks to see if the item has expired and should be removed from the cache. If it finds the 116 | * item and it has expired it will automatically remove it from the cache. 117 | * 118 | * @param value The value to check in the cache 119 | * @return true if it doesn't exist or has expired 120 | */ 121 | protected def isExpired(value: BasicInnerValue[Key, Value]): Boolean = synchronized { 122 | val currentTime = new LocalDateTime() 123 | val itemExpiry = value.localExpiry match { 124 | case Some(v) => v 125 | case None => cacheExpiry 126 | } 127 | if (currentTime.isAfter(value.accessTime.plus(Seconds.seconds(itemExpiry)))) { 128 | remove(value.key) 129 | true 130 | } else { 131 | false 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /app/org/maproulette/models/dal/TaskHistoryDal.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.models.dal 4 | 5 | import java.sql.Connection 6 | 7 | import anorm.SqlParser._ 8 | import anorm._ 9 | import javax.inject.{Inject, Provider, Singleton} 10 | import org.joda.time.{DateTime, DateTimeZone} 11 | import org.maproulette.exception.InvalidException 12 | import org.maproulette.models._ 13 | import org.maproulette.data._ 14 | import org.maproulette.Config 15 | import org.maproulette.permissions.Permission 16 | import org.maproulette.session.User 17 | import org.maproulette.session.dal.UserDAL 18 | import org.maproulette.provider.websockets.WebSocketProvider 19 | import play.api.db.Database 20 | import play.api.libs.ws.WSClient 21 | 22 | /** 23 | * @author krotstan 24 | */ 25 | @Singleton 26 | class TaskHistoryDAL @Inject()(override val db: Database, 27 | override val tagDAL: TagDAL, config: Config, 28 | override val permission: Permission, 29 | userDAL: Provider[UserDAL], 30 | projectDAL: Provider[ProjectDAL], 31 | challengeDAL: Provider[ChallengeDAL], 32 | notificationDAL: Provider[NotificationDAL], 33 | actions: ActionManager, 34 | statusActions: StatusActionManager, 35 | webSocketProvider: WebSocketProvider, 36 | ws: WSClient) 37 | extends TaskDAL(db, tagDAL, config, permission, userDAL, projectDAL, challengeDAL, notificationDAL, 38 | actions, statusActions, webSocketProvider, ws) { 39 | 40 | private val commentEntryParser: RowParser[TaskLogEntry] = { 41 | get[Long]("task_comments.task_id") ~ 42 | get[DateTime]("task_comments.created") ~ 43 | get[Int]("users.id") ~ 44 | get[String]("task_comments.comment") map { 45 | case taskId ~ created ~ userId ~ comment => new TaskLogEntry(taskId, created, 46 | TaskLogEntry.ACTION_COMMENT, Some(userId), None, None, None, None, None, None, Some(comment)) 47 | } 48 | } 49 | 50 | private val reviewEntryParser: RowParser[TaskLogEntry] = { 51 | get[Long]("task_id") ~ 52 | get[DateTime]("reviewed_at") ~ 53 | get[Option[DateTime]]("review_started_at") ~ 54 | get[Int]("review_status") ~ 55 | get[Int]("requested_by") ~ 56 | get[Option[Int]]("reviewed_by") map { 57 | case taskId ~ reviewedAt ~ reviewStartedAt ~ reviewStatus ~ requestedBy ~ 58 | reviewedBy => new TaskLogEntry(taskId, reviewedAt, 59 | TaskLogEntry.ACTION_REVIEW, None, None, None, reviewStartedAt, Some(reviewStatus), Some(requestedBy), 60 | reviewedBy, None) 61 | } 62 | } 63 | 64 | private val statusActionEntryParser: RowParser[TaskLogEntry] = { 65 | get[Long]("status_actions.task_id") ~ 66 | get[DateTime]("status_actions.created") ~ 67 | get[Int]("users.id") ~ 68 | get[Int]("status_actions.old_status") ~ 69 | get[Int]("status_actions.status") ~ 70 | get[Option[DateTime]]("status_actions.started_at") map { 71 | case taskId ~ created ~ userId ~ oldStatus ~ status ~ 72 | startedAt => new TaskLogEntry(taskId, created, 73 | TaskLogEntry.ACTION_STATUS_CHANGE, Some(userId), Some(oldStatus), Some(status), 74 | startedAt, None, None, None, None) 75 | } 76 | } 77 | 78 | private def sortByDate(entry1: TaskLogEntry, entry2: TaskLogEntry) = { 79 | entry1.timestamp.getMillis() < entry2.timestamp.getMillis() 80 | } 81 | 82 | /** 83 | * Returns a history log for the task -- includes comments, status actions, 84 | * review actions 85 | * @param taskId 86 | * @return List of TaskLogEntry 87 | */ 88 | def getTaskHistoryLog(taskId: Long)(implicit c: Option[Connection] = None): List[TaskLogEntry] = { 89 | this.withMRConnection { implicit c => 90 | val comments = 91 | SQL"""SELECT tc.task_id, tc.created, users.id, tc.comment FROM task_comments tc 92 | INNER JOIN users on users.osm_id=tc.osm_id 93 | WHERE task_id = $taskId""".as(this.commentEntryParser.*) 94 | 95 | val reviews = 96 | SQL"""SELECT * FROM task_review_history WHERE task_id = $taskId""".as(this.reviewEntryParser.*) 97 | 98 | val statusActions = 99 | SQL"""SELECT sa.task_id, sa.created, users.id, sa.old_status, sa.status, sa.started_at 100 | FROM status_actions sa 101 | INNER JOIN users on users.osm_id=sa.osm_user_id 102 | WHERE task_id = $taskId""".as(this.statusActionEntryParser.*) 103 | 104 | ((comments ++ reviews ++ statusActions).sortWith(sortByDate)).reverse 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /app/org/maproulette/cache/BasicCache.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.cache 4 | 5 | import org.joda.time.LocalDateTime 6 | import org.maproulette.Config 7 | 8 | import scala.collection.mutable.Map 9 | 10 | /** 11 | * This is a very basic Cache Storage class that will store all items in memory. Ultimately this 12 | * class should be extended to use the Play Cache API 13 | * 14 | * cacheLimit - The number of entries allowed in the cache until it starts kicking out the oldest entries 15 | * cacheExpiry - The number of seconds allowed until a cache item is expired out the cache lazily 16 | * 17 | * @author cuthbertm 18 | */ 19 | class BasicCache[Key, Value <: CacheObject[Key]](config: Config) extends Cache[Key, Value] { 20 | 21 | override implicit val cacheLimit: Int = config.cacheLimit 22 | override implicit val cacheExpiry: Int = config.cacheExpiry 23 | val cache: Map[Key, BasicInnerValue[Key, Value]] = Map.empty 24 | 25 | /** 26 | * Checks if an item is cached or not 27 | * 28 | * @param id The id of the object to check to see if it is in the cache 29 | * @return true if the item is found in the cache 30 | */ 31 | def isCached(id: Key): Boolean = this.cache.contains(id) 32 | 33 | /** 34 | * Fully clears the cache, this may not be applicable for non basic in memory caches 35 | */ 36 | def clear(): Unit = this.cache.clear() 37 | 38 | /** 39 | * The current size of the cache 40 | * 41 | * @return 42 | */ 43 | def size: Int = this.cache.size 44 | 45 | /** 46 | * True size is a little bit more accurate than size, however the performance will be a bit slower 47 | * as this size will loop through all the objects in the cache and expire out any items that have 48 | * expired. Thereby giving the true size at the end. 49 | * 50 | * @return 51 | */ 52 | def trueSize: Int = this.cache.keysIterator.count(!isExpiredByKey(_)) 53 | 54 | override protected def innerGet(key: Key): Option[BasicInnerValue[Key, Value]] = this.cache.get(key) 55 | 56 | /** 57 | * Adds an object to the cache, if cache limit has been reached, then will remove the oldest 58 | * accessed item in the cache 59 | * 60 | * @param obj The object to add to the cache 61 | * @param localExpiry You can add a custom expiry to a specific element in seconds 62 | * @return The object put in the cache, or None if it could not be placed in the cache 63 | */ 64 | def add(key: Key, obj: Value, localExpiry: Option[Int] = None): Option[Value] = synchronized { 65 | if (this.cache.size == cacheLimit) { 66 | val oldestEntry = this.cache.valuesIterator.reduceLeft((x, y) => if (x.accessTime.isBefore(y.accessTime)) x else y) 67 | remove(oldestEntry.key) 68 | } else if (this.cache.size > cacheLimit) { 69 | // something has gone very wrong if the cacheLimit has already been exceeded, this really shouldn't ever happen 70 | // in this case we go for the nuclear option, basically blow away the whole cache 71 | this.cache.clear() 72 | } 73 | this.cache.put(key, BasicInnerValue(key, obj, new LocalDateTime(), localExpiry)) match { 74 | case Some(value) => Some(value.value) 75 | case None => None 76 | } 77 | } 78 | 79 | /** 80 | * Removes an object from the cache based on the id 81 | * 82 | * @param id the id of the object to be removed 83 | * @return The object removed from the cache, or None if it could not be removed from the cache, 84 | * or was not originally in the cache 85 | */ 86 | def remove(id: Key): Option[Value] = synchronized { 87 | this.cache.remove(id) match { 88 | case Some(value) => Some(value.value) 89 | case None => None 90 | } 91 | } 92 | 93 | /** 94 | * Remove an object from the cache based on the name 95 | * 96 | * @param name The name of the object to be removed 97 | * @return The object removed from the cache, or None if it could not be removed from the cache, 98 | * or was not originally in the cache 99 | */ 100 | def remove(name: String): Option[Value] = synchronized { 101 | this.find(name) match { 102 | case Some(value) => this.remove(value.id) 103 | case None => None 104 | } 105 | } 106 | 107 | /** 108 | * Finds an object from the cache based on the name instead of the id 109 | * 110 | * @param name The name of the object you wish to find 111 | * @return The object from the cache, None if not found 112 | */ 113 | def find(name: String): Option[Value] = synchronized { 114 | this.cache.find(element => element._2.value.name.equalsIgnoreCase(name)) match { 115 | case Some(value) => 116 | if (isExpired(value._2)) { 117 | None 118 | } else { 119 | Some(value._2.value) 120 | } 121 | case None => None 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /conf/evolutions/default/11.sql: -------------------------------------------------------------------------------- 1 | # --- MapRoulette Scheme 2 | 3 | # --- !Ups 4 | -- Add display name column for projects 5 | SELECT add_drop_column('projects', 'display_name', 'character varying NULL');; 6 | SELECT add_drop_column('projects', 'owner_id', 'integer NOT NULL DEFAULT -999');; 7 | ALTER TABLE projects 8 | ADD CONSTRAINT projects_owner_id_fkey FOREIGN KEY (owner_id) 9 | REFERENCES users(osm_id) MATCH SIMPLE 10 | ON DELETE SET DEFAULT;; 11 | 12 | ALTER TABLE ONLY challenges ALTER COLUMN owner_id SET DEFAULT -999;; 13 | UPDATE challenges SET owner_id = -999 WHERE owner_id = -1;; 14 | ALTER TABLE challenges 15 | ADD CONSTRAINT challenges_owner_id_fkey FOREIGN KEY (owner_id) 16 | REFERENCES users(osm_id) MATCH SIMPLE 17 | ON DELETE SET DEFAULT;; 18 | 19 | -- update all display name columns to be the name of the user followed by the project 20 | -- Also update the owner_id to be the actual owner of the project 21 | DO $$ 22 | DECLARE 23 | rec RECORD;; 24 | owner INT;; 25 | display VARCHAR;; 26 | BEGIN 27 | FOR rec IN SELECT id, name FROM projects LOOP 28 | SELECT NULL INTO display;; 29 | SELECT NULL INTO owner;; 30 | IF rec.name LIKE 'Home_%' THEN 31 | SELECT osm_id FROM users WHERE osm_id = (SELECT REPLACE(rec.name, 'Home_', '')::INT) INTO owner;; 32 | SELECT name || '''s Project' FROM users WHERE osm_id = owner INTO display;; 33 | ELSE 34 | SELECT owner_id FROM challenges WHERE parent_id = rec.id LIMIT 1 INTO owner;; 35 | END IF;; 36 | IF owner IS NULL THEN 37 | SELECT -999 INTO owner;; 38 | END IF;; 39 | IF display IS NULL THEN 40 | SELECT rec.name INTO display;; 41 | END IF;; 42 | UPDATE projects SET 43 | owner_id = owner, 44 | display_name = display 45 | WHERE id = rec.id;; 46 | END LOOP;; 47 | END$$;; 48 | 49 | 50 | -- Add new bounding box that captures the bounding box for all the tasks within the current challenge 51 | DO $$ 52 | BEGIN 53 | PERFORM column_name FROM information_schema.columns WHERE table_name = 'challenges' AND column_name = 'bounding';; 54 | IF NOT FOUND THEN 55 | PERFORM AddGeometryColumn('challenges', 'bounding', 4326, 'POLYGON', 2);; 56 | END IF;; 57 | END$$;; 58 | -- Add spatial index for challenge location 59 | CREATE INDEX IF NOT EXISTS idx_challenges_location ON challenges USING GIST (location);; 60 | -- Add spatial index for challenge bounding box 61 | CREATE INDEX IF NOT EXISTS idx_challenges_bounding ON challenges USING GIST (bounding);; 62 | 63 | -- Table for all challenges, which is a child of Project, Surveys are also stored in this table 64 | CREATE TABLE IF NOT EXISTS saved_tasks 65 | ( 66 | id SERIAL NOT NULL PRIMARY KEY, 67 | created timestamp without time zone DEFAULT NOW(), 68 | user_id integer NOT NULL, 69 | task_id integer NOT NULL, 70 | challenge_id integer NOT NULL, 71 | CONSTRAINT saved_tasks_user_id FOREIGN KEY (user_id) 72 | REFERENCES users(id) MATCH SIMPLE 73 | ON UPDATE CASCADE ON DELETE CASCADE, 74 | CONSTRAINT saved_tasks_task_id FOREIGN KEY (task_id) 75 | REFERENCES tasks(id) MATCH SIMPLE 76 | ON UPDATE CASCADE ON DELETE CASCADE, 77 | CONSTRAINT saved_tasks_challenge_id FOREIGN KEY (challenge_id) 78 | REFERENCES challenges(id) MATCH SIMPLE 79 | ON UPDATE CASCADE ON DELETE CASCADE 80 | );; 81 | SELECT create_index_if_not_exists('saved_tasks', 'user_id', '(user_id)');; 82 | SELECT create_index_if_not_exists('saved_tasks', 'user_id_task_id', '(user_id, task_id)', true);; 83 | SELECT create_index_if_not_exists('saved_tasks', 'user_id_challenge_id', '(user_id, challenge_id)');; 84 | 85 | -- Create trigger on the tasks that if anything changes it updates the challenge modified date 86 | -- Function that is used by a trigger to updated the modified column in the table 87 | CREATE OR REPLACE FUNCTION update_tasks() RETURNS TRIGGER AS $$ 88 | DECLARE 89 | task RECORD;; 90 | BEGIN 91 | IF TG_OP='DELETE' THEN 92 | task = OLD;; 93 | ELSE 94 | NEW.modified = NOW();; 95 | task = NEW;; 96 | END IF;; 97 | UPDATE challenges SET modified = NOW() WHERE id = task.parent_id;; 98 | RETURN task;; 99 | END 100 | $$ 101 | LANGUAGE plpgsql VOLATILE;; 102 | DROP TRIGGER IF EXISTS update_tasks_modified ON tasks;; 103 | DROP TRIGGER IF EXISTS update_tasks_trigger ON tasks;; 104 | CREATE TRIGGER update_tasks_trigger BEFORE UPDATE OR INSERT OR DELETE ON tasks 105 | FOR EACH ROW EXECUTE PROCEDURE update_tasks();; 106 | 107 | -- Update the bounding box for all challenges 108 | DO $$ 109 | DECLARE 110 | rec RECORD;; 111 | BEGIN 112 | FOR rec IN SELECT id FROM challenges LOOP 113 | BEGIN 114 | UPDATE challenges SET bounding = (SELECT ST_Envelope(ST_Buffer((ST_SetSRID(ST_Extent(location), 4326))::geography,2)::geometry) 115 | FROM tasks 116 | WHERE parent_id = rec.id) 117 | WHERE id = rec.id;; 118 | EXCEPTION WHEN SQLSTATE 'XX000' THEN 119 | RAISE NOTICE 'Failed to create bounding for challenge %', rec.id;; 120 | END;; 121 | END LOOP;; 122 | END$$;; 123 | 124 | # --- !Downs 125 | -------------------------------------------------------------------------------- /app/org/maproulette/cache/RedisCache.scala: -------------------------------------------------------------------------------- 1 | package org.maproulette.cache 2 | 3 | import com.redis.serialization.Parse.Implicits._ 4 | import com.redis.{RedisClient, RedisClientPool} 5 | import org.maproulette.Config 6 | import org.maproulette.utils.{Readers, Writers} 7 | import play.api.libs.json.{Json, Reads, Writes} 8 | 9 | import scala.util.Try 10 | 11 | /** 12 | * @author mcuthbert 13 | */ 14 | class RedisCache[Key, Value <: CacheObject[Key]](config: Config, prefix: String) 15 | (implicit r: Reads[Value], w: Writes[Value]) 16 | extends Cache[Key, Value] with Readers with Writers { 17 | 18 | override implicit val cacheLimit: Int = config.cacheLimit 19 | override implicit val cacheExpiry: Int = config.cacheExpiry 20 | private val databaseId = Try(prefix.toInt).getOrElse(0) 21 | private val pool = new RedisClientPool(config.redisHost.getOrElse("localhost"), config.redisPort.getOrElse(6379)) 22 | 23 | /** 24 | * Checks if an item is cached or not 25 | * 26 | * @param key The id of the object to check to see if it is in the cache 27 | * @return true if the item is found in the cache 28 | */ 29 | override def isCached(key: Key): Boolean = this.withClient { client => 30 | client.exists(key) 31 | } 32 | 33 | /** 34 | * Fully clears the cache, this may not be applicable for non basic in memory caches 35 | */ 36 | override def clear(): Unit = this.withClient { client => 37 | client.keys[String]().foreach(client.del(_)) 38 | } 39 | 40 | /** 41 | * In Redis true size and size would be the same 42 | * 43 | * @return 44 | */ 45 | override def trueSize: Int = this.size 46 | 47 | /** 48 | * Retrieve all the keys and then just count it 49 | * 50 | * @return 51 | */ 52 | override def size: Int = this.withClient { client => 53 | client.keys[String]().get.size 54 | } 55 | 56 | /** 57 | * Adds an object to the cache, if cache limit has been reached, then will remove the oldest 58 | * accessed item in the cache 59 | * 60 | * @param key The object to add to the cache 61 | * @param value You can add a custom expiry to a specific element in seconds 62 | * @return The object put in the cache, or None if it could not be placed in the cache 63 | */ 64 | override def add(key: Key, value: Value, localExpiry: Option[Int]): Option[Value] = this.withClient { client => 65 | client.set(value.name, key.toString) 66 | client.expire(value.name, this.cacheExpiry) 67 | if (client.set(key.toString, serialise(key, value))) { 68 | client.expire(key.toString, this.cacheExpiry) 69 | Some(value) 70 | } else { 71 | None 72 | } 73 | } 74 | 75 | private def serialise(key: Key, value: Value): String = { 76 | Json.toJson(value).toString() 77 | } 78 | 79 | override def get(key: Key): Option[Value] = this.withClient { client => 80 | deserialise(client.get[String](key)) 81 | } 82 | 83 | // ignore this function for Redis 84 | override def innerGet(key: Key): Option[BasicInnerValue[Key, Value]] = None 85 | 86 | /** 87 | * Finds an object from the cache based on the name instead of the id 88 | * 89 | * @param name The name of the object you wish to find 90 | * @return The object from the cache, None if not found 91 | */ 92 | override def find(name: String): Option[Value] = this.withClient { client => 93 | client.get[String](name) match { 94 | case Some(k) => deserialise(client.get[String](k)) 95 | case None => None 96 | } 97 | } 98 | 99 | /** 100 | * Remove an object from the cache based on the name 101 | * 102 | * @param name The name of the object to be removed 103 | * @return The object removed from the cache, or None if it could not be removed from the cache, 104 | * or was not originally in the cache 105 | */ 106 | override def remove(name: String): Option[Value] = this.withClient { client => 107 | client.get[Array[Byte]](name) match { 108 | case Some(k) => 109 | val deserializedObject = deserialise(client.get[String](k)) 110 | client.del(k) 111 | client.del(name) 112 | deserializedObject 113 | case None => None 114 | } 115 | } 116 | 117 | override def remove(id: Key): Option[Value] = this.withClient { client => 118 | client.get[Array[Byte]](id.toString) match { 119 | case Some(o) => 120 | val deserializedObject = deserialise(Some(o)).get 121 | client.del(deserializedObject.name) 122 | client.del(id.toString) 123 | Some(deserializedObject) 124 | case None => None 125 | } 126 | } 127 | 128 | private def deserialise(value: Option[String]): Option[Value] = { 129 | value match { 130 | case Some(s) => 131 | Some(Json.fromJson[Value](Json.parse(s)).get) 132 | case None => None 133 | } 134 | } 135 | 136 | private def withClient[T](body: RedisClient => T): T = { 137 | this.pool.withClient { client => 138 | client.select(this.databaseId) 139 | body(client) 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /app/org/maproulette/jobs/Scheduler.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.jobs 4 | 5 | import java.util.Calendar 6 | 7 | import akka.actor.{ActorRef, ActorSystem} 8 | import javax.inject.{Inject, Named} 9 | import org.maproulette.Config 10 | import org.maproulette.jobs.SchedulerActor.RunJob 11 | import org.slf4j.LoggerFactory 12 | import play.api.{Application, Logger} 13 | 14 | import scala.concurrent.ExecutionContext 15 | import scala.concurrent.duration._ 16 | 17 | /** 18 | * @author cuthbertm 19 | * @author davis_20 20 | */ 21 | class Scheduler @Inject()(val system: ActorSystem, 22 | @Named("scheduler-actor") val schedulerActor: ActorRef, 23 | val config: Config) 24 | (implicit application: Application, ec: ExecutionContext) { 25 | 26 | private val logger = LoggerFactory.getLogger(this.getClass) 27 | 28 | schedule("cleanLocks", "Cleaning locks", 1.minute, Config.KEY_SCHEDULER_CLEAN_LOCKS_INTERVAL) 29 | schedule("cleanClaimLocks", "Cleaning review claim locks", 1.minute, Config.KEY_SCHEDULER_CLEAN_CLAIM_LOCKS_INTERVAL) 30 | schedule("runChallengeSchedules", "Running challenge Schedules", 1.minute, Config.KEY_SCHEDULER_RUN_CHALLENGE_SCHEDULES_INTERVAL) 31 | schedule("updateLocations", "Updating locations", 1.minute, Config.KEY_SCHEDULER_UPDATE_LOCATIONS_INTERVAL) 32 | schedule("cleanOldTasks", "Cleaning old tasks", 1.minute, Config.KEY_SCHEDULER_CLEAN_TASKS_INTERVAL) 33 | schedule("cleanExpiredVirtualChallenges", "Cleaning up expired Virtual Challenges", 1.minute, Config.KEY_SCHEDULER_CLEAN_VC_INTEVAL) 34 | schedule("OSMChangesetMatcher", "Matches OSM changesets to tasks", 1.minute, Config.KEY_SCHEDULER_OSM_MATCHER_INTERVAL) 35 | schedule("cleanDeleted", "Deleting Project/Challenges", 1.minute, Config.KEY_SCHEDULER_CLEAN_DELETED) 36 | schedule("KeepRightUpdate", "Updating KeepRight Challenges", 1.minute, Config.KEY_SCHEDULER_KEEPRIGHT) 37 | schedule("rebuildChallengesLeaderboard", "Rebuilding Challenges Leaderboard", 1.minute, Config.KEY_SCHEDULER_CHALLENGES_LEADERBOARD) 38 | schedule("sendImmediateNotificationEmails", "Sending Immediate Notification Emails", 1.minute, Config.KEY_SCHEDULER_NOTIFICATION_IMMEDIATE_EMAIL_INTERVAL) 39 | scheduleAtTime("sendDigestNotificationEmails", "Sending Notification Email Digests", 40 | config.getValue(Config.KEY_SCHEDULER_NOTIFICATION_DIGEST_EMAIL_START), Config.KEY_SCHEDULER_NOTIFICATION_DIGEST_EMAIL_INTERVAL) 41 | 42 | // Run the rebuild of the country leaderboard at 43 | scheduleAtTime("rebuildCountryLeaderboard", "Rebuilding Country Leaderboard", 44 | config.getValue(Config.KEY_SCHEDULER_COUNTRY_LEADERBOARD_START), Config.KEY_SCHEDULER_COUNTRY_LEADERBOARD) 45 | 46 | // Run the user metrics snapshot at 47 | scheduleAtTime("snapshotUserMetrics", "Snapshotting User Metrics", 48 | config.getValue(Config.KEY_SCHEDULER_SNAPSHOT_USER_METRICS_START), Config.KEY_SCHEDULER_SNAPSHOT_USER_METRICS) 49 | 50 | /** 51 | * Conditionally schedules message event to start at an initial time and run every duration 52 | * 53 | * @param name The message name sent to the SchedulerActor 54 | * @param action The action this job is performing for logging 55 | * @param initialRunTime String time in format "00:00:00" 56 | * @param intervalKey Configuration key that, when set, will enable periodic scheduled messages 57 | */ 58 | def scheduleAtTime(name: String, action: String, initialRunTime: Option[String], intervalKey: String): Unit = { 59 | initialRunTime match { 60 | case Some(initialRunTime) => 61 | val timeValues = initialRunTime.split(":") 62 | val c = Calendar.getInstance() 63 | c.set(Calendar.HOUR_OF_DAY, timeValues(0).toInt) 64 | c.set(Calendar.MINUTE, timeValues(1).toInt) 65 | c.set(Calendar.SECOND, timeValues(2).toInt) 66 | c.set(Calendar.MILLISECOND, 0) 67 | 68 | if (c.getTimeInMillis() < System.currentTimeMillis()) { 69 | c.add(Calendar.DATE, 1) 70 | } 71 | val msBeforeStart = c.getTimeInMillis() - System.currentTimeMillis() 72 | 73 | logger.debug("Scheduling " + action + " to run in " + msBeforeStart + "ms.") 74 | schedule(name, action, msBeforeStart.milliseconds, intervalKey) 75 | 76 | case _ => logger.error("Invalid start time given for " + action + "!") 77 | } 78 | 79 | } 80 | 81 | /** 82 | * Conditionally schedules message event when configured with a valid duration 83 | * 84 | * @param name The message name sent to the SchedulerActor 85 | * @param action The action this job is performing for logging 86 | * @param initialDelay FiniteDuration until the initial message is sent 87 | * @param intervalKey Configuration key that, when set, will enable periodic scheduled messages 88 | */ 89 | def schedule(name: String, action: String, initialDelay: FiniteDuration, intervalKey: String): Unit = { 90 | config.withFiniteDuration(intervalKey) { 91 | interval => 92 | this.system.scheduler.schedule(initialDelay, interval, this.schedulerActor, RunJob(name, action)) 93 | logger.info(s"$action every $interval") 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /test/org/maproulette/utils/TestSpec.scala: -------------------------------------------------------------------------------- 1 | package org.maproulette.utils 2 | 3 | import com.google.inject.util.Providers 4 | import org.joda.time.DateTime 5 | import org.maproulette.data.{ActionManager, DataManager, StatusActionManager} 6 | import org.maproulette.models._ 7 | import org.maproulette.models.dal._ 8 | import org.maproulette.permissions.Permission 9 | import org.maproulette.session._ 10 | import org.maproulette.session.dal.{UserDAL, UserGroupDAL} 11 | import org.mockito.ArgumentMatchers.{eq => eqM, _} 12 | import org.mockito.Mockito._ 13 | import org.scalatest.mockito.MockitoSugar 14 | import play.api.db.Databases 15 | import play.api.libs.oauth.RequestToken 16 | 17 | /** 18 | * @author mcuthbert 19 | */ 20 | trait TestSpec extends MockitoSugar { 21 | 22 | val testDb = Databases.inMemory() 23 | 24 | // 3x Groups (Admin, Write, Read) 25 | val adminGroup = Group(1, "Mocked_1_Admin", 1, Group.TYPE_ADMIN) 26 | val writeGroup = Group(2, "Mocked_1_Write", 1, Group.TYPE_WRITE_ACCESS) 27 | val readGroup = Group(3, "Mocked_1_Read", 1, Group.TYPE_READ_ONLY) 28 | 29 | //projects 30 | val project1 = Project(1, 101, "Mocked_1", DateTime.now(), DateTime.now(), None, 31 | List(adminGroup, writeGroup, readGroup) 32 | ) 33 | 34 | //challenges 35 | val challenge1 = Challenge(1, "Challenge1", DateTime.now(), DateTime.now(), None, false, None, 36 | ChallengeGeneral(101, 1, ""), 37 | ChallengeCreation(), 38 | ChallengePriority(), 39 | ChallengeExtra() 40 | ) 41 | 42 | //surveys 43 | val survey1 = Challenge(1, "Survey1", DateTime.now(), DateTime.now(), None, false, None, 44 | ChallengeGeneral(101, 1, ""), 45 | ChallengeCreation(), 46 | ChallengePriority(), 47 | ChallengeExtra() 48 | ) 49 | 50 | //tasks 51 | val task1 = Task(1, "Task1", DateTime.now(), DateTime.now(), 1, None, None, "") 52 | 53 | val tagDAL = mock[TagDAL] 54 | when(tagDAL.retrieveById(0)).thenReturn(None) 55 | when(tagDAL.retrieveById(1)).thenReturn(Some(Tag(1, "Tag"))) 56 | 57 | val taskDAL = mock[TaskDAL] 58 | when(taskDAL.retrieveById(0)).thenReturn(None) 59 | when(taskDAL.retrieveById(1)).thenReturn(Some(task1)) 60 | when(taskDAL.retrieveRootObject(eqM(Right(task1)), any())(any())).thenReturn(Some(project1)) 61 | 62 | val challengeDAL = mock[ChallengeDAL] 63 | when(challengeDAL.retrieveById(0)).thenReturn(None) 64 | when(challengeDAL.retrieveById(1)).thenReturn(Some(challenge1)) 65 | when(challengeDAL.retrieveRootObject(eqM(Right(challenge1)), any())(any())).thenReturn(Some(project1)) 66 | 67 | val virtualChallengeDAL = mock[VirtualChallengeDAL] 68 | when(virtualChallengeDAL.retrieveById(0)).thenReturn(None) 69 | when(virtualChallengeDAL.retrieveById(1)).thenReturn(Some(VirtualChallenge(1, "VChallenge", DateTime.now(), DateTime.now(), None, 101, SearchParameters(), DateTime.now()))) 70 | 71 | val surveyDAL = mock[SurveyDAL] 72 | when(surveyDAL.retrieveById(0)).thenReturn(None) 73 | when(surveyDAL.retrieveById(1)).thenReturn(Some(survey1)) 74 | 75 | val projectDAL = mock[ProjectDAL] 76 | when(projectDAL.retrieveById(0)).thenReturn(None) 77 | when(projectDAL.retrieveById(1)).thenReturn(Some(project1)) 78 | 79 | val userDAL = mock[UserDAL] 80 | when(userDAL.retrieveById(-999)).thenReturn(Some(User.superUser)) 81 | when(userDAL.retrieveById(-1)).thenReturn(Some(User.guestUser)) 82 | when(userDAL.retrieveById(0)).thenReturn(None) 83 | when(userDAL.retrieveById(1)).thenReturn(Some(User(1, DateTime.now(), DateTime.now(), 84 | OSMProfile(1, "AdminUser", "", "", Location(0, 0), DateTime.now(), RequestToken("", "")), 85 | List(adminGroup))) 86 | ) 87 | when(userDAL.retrieveById(2)).thenReturn(Some(User(2, DateTime.now(), DateTime.now(), 88 | OSMProfile(2, "WriteUser", "", "", Location(0, 0), DateTime.now(), RequestToken("", "")), 89 | List(writeGroup))) 90 | ) 91 | when(userDAL.retrieveById(3)).thenReturn(Some(User(3, DateTime.now(), DateTime.now(), 92 | OSMProfile(3, "ReadUser", "", "", Location(0, 0), DateTime.now(), RequestToken("", "")), 93 | List(readGroup))) 94 | ) 95 | when(userDAL.retrieveById(100)).thenReturn(Some(User(100, DateTime.now(), DateTime.now(), 96 | OSMProfile(101, "DefaultOwner", "", "", Location(0, 0), DateTime.now(), RequestToken("", "")), 97 | List.empty //generally an owner would have to be in an admin group, but here we are making sure the owner permissions are respective regardless or group or lack there of 98 | ))) 99 | 100 | val userGroupDAL = mock[UserGroupDAL] 101 | when(userGroupDAL.getGroup(0)).thenReturn(None) 102 | when(userGroupDAL.getGroup(1)).thenReturn(Some(adminGroup)) 103 | when(userGroupDAL.getGroup(2)).thenReturn(Some(writeGroup)) 104 | when(userGroupDAL.getGroup(3)).thenReturn(Some(readGroup)) 105 | 106 | 107 | val actionManager = mock[ActionManager] 108 | val dataManager = mock[DataManager] 109 | val statusActionManager = mock[StatusActionManager] 110 | var notificationDAL = mock[NotificationDAL] 111 | val dalManager = new DALManager(tagDAL, taskDAL, challengeDAL, virtualChallengeDAL, 112 | surveyDAL, projectDAL, userDAL, userGroupDAL, notificationDAL, actionManager, dataManager, 113 | statusActionManager) 114 | val permission = new Permission(Providers.of[DALManager](dalManager)) 115 | } 116 | -------------------------------------------------------------------------------- /app/org/maproulette/controllers/api/SurveyController.scala: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2019 MapRoulette contributors (see CONTRIBUTORS.md). 2 | // Licensed under the Apache License, Version 2.0 (see LICENSE). 3 | package org.maproulette.controllers.api 4 | 5 | import javax.inject.Inject 6 | import org.maproulette.Config 7 | import org.maproulette.data.{ActionManager, QuestionAnswered, SurveyType} 8 | import org.maproulette.exception.NotFoundException 9 | import org.maproulette.models.dal._ 10 | import org.maproulette.models.{Answer, Challenge} 11 | import org.maproulette.permissions.Permission 12 | import org.maproulette.provider.ChallengeProvider 13 | import org.maproulette.session.{SessionManager, User} 14 | import org.maproulette.utils.Utils 15 | import play.api.libs.json._ 16 | import play.api.libs.ws.WSClient 17 | import play.api.mvc._ 18 | 19 | /** 20 | * The survey controller handles all operations for the Survey objects. 21 | * This includes CRUD operations and searching/listing. 22 | * See ParentController for more details on parent object operations 23 | * See CRUDController for more details on CRUD object operations 24 | * 25 | * @author cuthbertm 26 | */ 27 | class SurveyController @Inject()(override val childController: TaskController, 28 | override val sessionManager: SessionManager, 29 | override val actionManager: ActionManager, 30 | override val dal: SurveyDAL, 31 | dalManager: DALManager, 32 | override val tagDAL: TagDAL, 33 | challengeProvider: ChallengeProvider, 34 | wsClient: WSClient, 35 | permission: Permission, 36 | config: Config, 37 | components: ControllerComponents, 38 | override val bodyParsers: PlayBodyParsers) 39 | extends ChallengeController(childController, sessionManager, actionManager, dalManager.challenge, dalManager, tagDAL, challengeProvider, wsClient, permission, config, components, bodyParsers) { 40 | 41 | // The type of object that this controller deals with. 42 | override implicit val itemType = SurveyType() 43 | 44 | /** 45 | * Classes can override this function to inject values into the object before it is sent along 46 | * with the response 47 | * 48 | * @param obj the object being sent in the response 49 | * @return A Json representation of the object 50 | */ 51 | override def inject(obj: Challenge)(implicit request: Request[Any]) = { 52 | val json = super.inject(obj) 53 | // if no answers provided with Challenge, then provide the default answers 54 | val answers = this.dalManager.survey.getAnswers(obj.id) match { 55 | case a if a.isEmpty => List(Challenge.defaultAnswerValid, Challenge.defaultAnswerInvalid) 56 | case a => a 57 | } 58 | Utils.insertIntoJson(json, Challenge.KEY_ANSWER, Json.toJson(answers)) 59 | } 60 | 61 | /** 62 | * This function allows sub classes to modify the body, primarily this would be used for inserting 63 | * default elements into the body that shouldn't have to be required to create an object. 64 | * 65 | * @param body The incoming body from the request 66 | * @return 67 | */ 68 | override def updateCreateBody(body: JsValue, user: User): JsValue = { 69 | val jsonBody = super.updateCreateBody(body, user: User) 70 | //if answers are supplied in a simple json string array, then convert to the answer types 71 | val answerArray = (jsonBody \ "answers").as[List[String]].map(a => Answer(answer = a)) 72 | Utils.insertIntoJson(jsonBody, "answers", answerArray, true) 73 | } 74 | 75 | /** 76 | * Answers a question for a survey 77 | * 78 | * @param challengeId The id of the survey 79 | * @param taskId The id of the task being viewed 80 | * @param answerId The id of the answer 81 | * @return 82 | */ 83 | def answerSurveyQuestion(challengeId: Long, taskId: Long, answerId: Long, comment: String): Action[AnyContent] = Action.async { implicit request => 84 | this.sessionManager.authenticatedRequest { implicit user => 85 | // make sure that the survey and answer exists first 86 | this.dal.retrieveById(challengeId) match { 87 | case Some(challenge) => 88 | val ans = if (answerId != Challenge.defaultAnswerValid.id && answerId != Challenge.defaultAnswerInvalid.id) { 89 | this.dalManager.survey.getAnswers(challengeId).find(_.id == answerId) match { 90 | case None => 91 | throw new NotFoundException(s"Requested answer [$answerId] for survey does not exist.") 92 | case Some(a) => a.answer 93 | } 94 | } else if (answerId == Challenge.defaultAnswerValid.id) { 95 | Challenge.defaultAnswerValid.answer 96 | } else { 97 | Challenge.defaultAnswerInvalid.answer 98 | } 99 | this.dal.answerQuestion(challenge, taskId, answerId, user) 100 | this.childController.customTaskStatus(taskId, QuestionAnswered(answerId), user, comment) 101 | NoContent 102 | case None => throw new NotFoundException(s"Requested survey [$challengeId] to answer question from does not exist.") 103 | } 104 | } 105 | } 106 | } 107 | --------------------------------------------------------------------------------