├── 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 |
--------------------------------------------------------------------------------