diff --git a/.gitignore b/.gitignore index 098341e..ebe9c2d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ build/ deploy_templates/* deploy_files/* examples/responses.json -maproulette/static/js/maproulette.js \ No newline at end of file +maproulette/static/js/maproulette.js +maproulette/static/js/.module-cache/ \ No newline at end of file diff --git a/jsx/maproulette.js b/jsx/maproulette.js index dae6218..72163a3 100644 --- a/jsx/maproulette.js +++ b/jsx/maproulette.js @@ -1,5 +1,7 @@ /** @jsx React.DOM */ +var ReactCSSTransitionGroup = React.addons.CSSTransitionGroup; + // React Components var Button = React.createClass({ render: function(){ @@ -11,6 +13,16 @@ var Button = React.createClass({ ); }}); +var AreaSelectButton = React.createClass({ + render: function(){ + return ( +
+ {this.props.userHasArea ? 'I want to clear my editing area or select a new one' : 'I want to select an area to work in'} +
+ ); + }}); + var ActionButton = React.createClass({ render: function(){ var action = this.props.action; @@ -58,6 +70,27 @@ var DifficultyBadge = React.createClass({ }); var ChallengeBox = React.createClass({ + + getInitialState: function () { + return { + stats: {"total": 0, "unfixed": 0} + } + }, + + componentWillMount: function () { + $.ajax({ + url: "/api/stats/challenge/" + this.props.challenge.slug, + dataType: 'json', + success: function(data) { + console.log(data); + this.setState({"stats": data}) + if (this.state.stats.total == 0 || this.state.stats.unfixed == 0) { + this.getDOMNode().style.display = "none"; + }; + }.bind(this) + }) + }, + render: function(){ var slug = this.props.challenge.slug; var pickMe = function(){ @@ -68,6 +101,7 @@ var ChallengeBox = React.createClass({ {this.props.challenge.title}

{this.props.challenge.blurb}

+

total tasks: {this.state.stats.total}, available: {this.state.stats.unfixed}

); @@ -76,7 +110,9 @@ var ChallengeBox = React.createClass({ var ChallengeSelectionDialog = React.createClass({ getInitialState: function() { - return {challenges: []}; + return { + challenges: [], + usersettings: {}}; }, componentWillMount: function(){ $.ajax({ @@ -89,17 +125,27 @@ var ChallengeSelectionDialog = React.createClass({ this.setState({challenges: data}); }.bind(this) }) + $.ajax({ + url: "/api/me", + dataType: 'json', + success: function(data) { + this.setState({usersettings: data}); + }.bind(this) + }) }, render: function(){ var challengeBoxes = this.state.challenges.map(function(challenge){ return ; }); return ( -
-

Pick a different challenge

- {challengeBoxes} - Nevermind -
+ +
+

Pick a different challenge

+ + {challengeBoxes} + Nevermind +
+
); }} ); @@ -290,7 +336,8 @@ var MRConfig = (function () { // the default map options mapOptions: { center: new L.LatLng(40, -90), - zoom: 4 + zoom: 4, + keyboard: false }, // default tile URL @@ -306,6 +353,8 @@ var MRConfig = (function () { var MRManager = (function () { var map; + var editArea; + var MAX_EDIT_RADIUS = 512000 // 512 km var challenges = []; var challenge = {}; var task = {}; @@ -315,7 +364,14 @@ var MRManager = (function () { 'lat': parseFloat(Q.lat) } : {}; var difficulty = parseInt(Q.difficulty); - var taskLayer; + var taskLayer = new L.geoJson(null, { + onEachFeature: function (feature, layer) { + if (feature.properties && feature.properties.text) { + layer.bindPopup(feature.properties.text); + return layer.openPopup(); + } + } + }); // create a notifier notify = MRNotifier; @@ -420,16 +476,6 @@ var MRManager = (function () { attribution: MRConfig.tileAttrib }); - // and the GeoJSON layer - taskLayer = new L.geoJson(null, { - onEachFeature: function (feature, layer) { - if (feature.properties && feature.properties.text) { - layer.bindPopup(feature.properties.text); - return layer.openPopup(); - } - } - }); - // Add both the tile layer and the task layer to the map map.addLayer(tileLayer); map.addLayer(taskLayer); @@ -516,6 +562,7 @@ var MRManager = (function () { $('#challenge_title').text(challenge.title); $('#challenge_blurb').text(challenge.blurb); // and move on to get the stats + getChallengeStats(); if (presentDialog) presentChallengeDialog(); }, @@ -524,16 +571,16 @@ var MRManager = (function () { var getChallengeStats = function () { - // now get the challenge stats - // var endpoint = '/api/stats/challenge/' + challenge.slug; - // $.getJSON(endpoint, function (data) { - // for (key in data) { - // console.log('raw value: ' + data[key]); - // var value = parseInt(data[key]) > 10 ? 'about ' + (~~((parseInt(data[key]) + 5) / 10) * 10) : 'only a few'; - // console.log('value for ' + key + ': ' + value); - // $('#challenge_' + key).html(value).fadeIn(); - // }; - // }); + //now get the challenge stats + var endpoint = '/api/stats/challenge/' + challenge.slug; + $.getJSON(endpoint, function (data) { + for (key in data) { + console.log('raw value: ' + data[key]); + var value = parseInt(data[key]) > 10 ? 'about ' + (~~((parseInt(data[key]) + 5) / 10) * 10) : 'only a few'; + console.log('value for ' + key + ': ' + value); + $('#challenge_' + key).html(value).fadeIn(); + }; + }); }; var updateTask = function (action) { @@ -785,9 +832,98 @@ var MRManager = (function () { nextTask(); }; - var userPreferences = function () { - //FIXME implement - }; + var userPickEditLocation = function () { + notify.play('Click on the map to let MapRoulette know where you want to edit.
' + + 'You can use the +/- on your keyboard to increase the radius of your area.

' + + 'When you are satisfied with your selection, Click this notification to confirm your selection.
' + + 'To unset your editing area, or cancel, click this dialog without making a selection.', { + killer: true, + timeout: false, + callback: { + afterClose: MRManager.confirmPickingLocation + }, + }); + // remove geoJSON layer + map.removeLayer(taskLayer); + // set zoom level + if (map.getZoom() > 10) map.setZoom(10, true); + // add area on click + map.on('click', MRManager.isPickingLocation); + // add handlers for increasing radius + $(document).bind('keypress.plusminus', function (e) { + if (map.hasLayer(editArea)) { + if (editArea.getRadius() < MAX_EDIT_RADIUS && (e.which == 43 || e.which == 61)) { // plus + editArea.setRadius(editArea.getRadius() + (100 * 18 - Math.max(9, map.getZoom()))); // zoom dependent increase + } else if (editArea.getRadius() > 0 && (e.which == 45 || e.which == 95)) { // minus + editArea.setRadius(editArea.getRadius() - (100 * 18 - Math.max(9, map.getZoom()))); // zoom dependent decrease + } + } + }); + } + + var isPickingLocation = function (e) { + var zoomDependentEditRadius = 100 * Math.pow(2, 18 - Math.max(6, map.getZoom())); + if (map.hasLayer(editArea)) { + zoomDependentEditRadius = editArea.getRadius(); + map.removeLayer(editArea); + }; + editArea = new L.Circle(e.latlng, zoomDependentEditRadius) + editArea.addTo(map); + } + + var confirmPickingLocation = function() { + var data = {}; + if (!editArea) { + data ={ + "lon" : null, + "lat" : null, + "radius" : null + }; + $('#msg_editarea').hide(); + notify.play('You cleared your designated editing area.', {killer: true}); + } else { + data = { + "lon" : editArea.getLatLng().lng, + "lat" : editArea.getLatLng().lat, + "radius" : editArea.getRadius() + }; + $('#msg_editarea').show(); + notify.play('You have set your preferred editing location.', {killer: true}) + console.log(editArea.toGeoJSON()); + }; + storeServerSettings(data); + if(map.hasLayer(editArea)) map.removeLayer(editArea); + editArea = null; + map.addLayer(taskLayer); + map.off('click', MRHelpers.isPickingLocation); + $(document).unbind('keypress.plusminus', false); + getChallengeStats(); + setTimeout(MRManager.presentChallengeSelectionDialog(), 4000); + } + + var getServerSettings = function(keys) { + // This gets the server stored session settings for the given array of keys from /api/me + // untested and not used yet anywhere + $.getJSON('/api/me', function(data) { + var out = {}; + for (var i = 0; i < keys.length; i++) { + if (keys[i] in data && data[keys[i]] != null) { + out[keys[i]] = data[keys[i]]; + } + }; + return out; + }); + } + + var storeServerSettings = function(data) { + // This stores a dict of settings on the server + $.ajax({ + url: "/api/me", + type: "PUT", + contentType: "application/json", + data: JSON.stringify(data) + }); + } var registerHotkeys = function () { $(document).bind('keypress', 'q', function () { @@ -929,14 +1065,18 @@ var MRManager = (function () { openTaskInId: openTaskInId, openTaskInJosm: openTaskInJosm, geolocateUser: geolocateUser, - userPreferences: userPreferences, userPickChallenge: userPickChallenge, + userPickEditLocation: userPickEditLocation, + isPickingLocation: isPickingLocation, + confirmPickingLocation: confirmPickingLocation, readyToEdit: readyToEdit, presentChallengeSelectionDialog: presentChallengeSelectionDialog, presentChallengeHelp: presentChallengeHelp, registerHotkeys: registerHotkeys, displayUserStats: displayUserStats, displayAllChallengesStats: displayAllChallengesStats, + getServerSettings: getServerSettings, + storeServerSettings: storeServerSettings }; } ()); diff --git a/manage.py b/manage.py index e250d79..49f9041 100644 --- a/manage.py +++ b/manage.py @@ -103,9 +103,12 @@ def create_testdata(challenges=10, tasks=100): osmids = [random.randrange(1000000, 1000000000) for _ in range(2)] # add the first point and the linestring to the task's geometries task.geometries.append(TaskGeometry(osmids[0], p1)) - task.geometries.append(TaskGeometry(osmids[1], l1)) - # and add the first point as the task's location - task.location = p1 + # set a linestring for every other challenge + if not j % 2: + task.geometries.append(TaskGeometry(osmids[1], l1)) + # because we are not using the API, we need to call set_location + # explicitly to set the task's location + task.set_location() # generate random string for the instruction task.instruction = task_instruction_text # add the task to the session @@ -130,7 +133,7 @@ def clean_stale_tasks(): for task in db.session.query(Task).filter( Task.status.in_(['assigned', 'editing'])).join( Task.actions).group_by( - Task.id).having(max(Action.timestamp) < stale_threshold).all(): + Task.id).having(max(Action.timestamp) < stale_threshold).all(): task.append_action(Action("available")) db.session.add(task) print "setting task %s to available" % (task.identifier) @@ -139,5 +142,20 @@ def clean_stale_tasks(): print 'done. %i tasks made available' % counter +@manager.command +def populate_task_location(): + """This command populates the new location field for each task""" + from maproulette.models import db, Task, Challenge + for challenge in db.session.query(Challenge): + counter = 0 + for task in db.session.query(Task).filter_by( + challenge_slug=challenge.slug): + task.set_location() + counter += 1 + db.session.commit() + print 'done. Location for %i tasks in challenge %s set' %\ + (counter, challenge.title) + + if __name__ == "__main__": manager.run() diff --git a/maproulette/api/__init__.py b/maproulette/api/__init__.py index 9231c75..6d24cfc 100644 --- a/maproulette/api/__init__.py +++ b/maproulette/api/__init__.py @@ -2,19 +2,19 @@ from flask.ext.restful import reqparse, fields, marshal, \ marshal_with, Api, Resource from flask.ext.restful.fields import Raw -from flask import session, make_response, request, abort, url_for +from flask import session, request, abort, url_for from maproulette.helpers import get_random_task,\ get_challenge_or_404, get_task_or_404,\ require_signedin, osmerror, challenge_exists,\ - parse_task_json -from maproulette.models import User, Challenge, Task, TaskGeometry, Action, db + parse_task_json, refine_with_user_area, user_area_is_defined,\ + send_email +from maproulette.models import User, Challenge, Task, Action, db from sqlalchemy.sql import func from geoalchemy2.functions import ST_Buffer -from shapely.geometry.base import BaseGeometry +from geoalchemy2.shape import to_shape import geojson import json import markdown -import requests class ProtectedResource(Resource): @@ -28,13 +28,7 @@ class PointField(Raw): """An encoded point""" def format(self, geometry): - # if we get a linestring, take the first point, - # else, just get the point. - if hasattr(geometry, 'coords'): - point = geometry.coords[0] - return '%f|%f' % point - else: - return '0|0' # this should not happen + return '%f|%f' % to_shape(geometry).coords[0] class MarkdownField(Raw): @@ -63,7 +57,10 @@ def format(self, text): me_fields = { 'username': fields.String(attribute='display_name'), - 'osm_id': fields.String() + 'osm_id': fields.String, + 'lat': fields.Float, + 'lon': fields.Float, + 'radius': fields.Integer } action_fields = { @@ -73,49 +70,20 @@ def format(self, text): 'user': fields.String(attribute='user_id') } - api = Api(app) # override the default JSON representation to support the geo objects -@api.representation('application/json') -def output_json(data, code, headers=None): - """Automatic JSON / GeoJSON output""" - # return empty result if data contains nothing - if not data: - resp = make_response(geojson.dumps({}), code) - # if this is a Shapely object, dump it as geojson - elif isinstance(data, BaseGeometry): - resp = make_response(geojson.dumps(data), code) - # if this is a list of task geometries, we need to unpack it - elif not isinstance(data, dict) and isinstance(data[0], TaskGeometry): - # unpack the geometries FIXME can this be done in the model? - geometries = [geojson.Feature( - geometry=g.geometry, - properties={ - 'selected': True, - 'osmid': g.osmid}) for g in data] - resp = make_response( - geojson.dumps(geojson.FeatureCollection(geometries)), - code) - # otherwise perform default json representation - else: - resp = make_response(json.dumps(data), code) - # finish and return the response object - resp.headers.extend(headers or {}) - return resp - - class ApiPing(Resource): """a simple ping endpoint""" def get(self): - return "I am alive" + return ["I am alive"] -class ApiMe(ProtectedResource): +class ApiStatsMe(ProtectedResource): def get(self): me = {} @@ -171,7 +139,7 @@ class ApiChallengeList(ProtectedResource): """Challenge list endpoint""" @marshal_with(challenge_summary) - def get(self): + def get(self, **kwargs): """returns a list of challenges. Optional URL parameters are: difficulty: the desired difficulty to filter on @@ -180,14 +148,19 @@ def get(self): challenges whose bounding polygons contain this point) example: /api/challenges?lon=-100.22&lat=40.45&difficulty=2 """ + # initialize the parser parser = reqparse.RequestParser() parser.add_argument('difficulty', type=int, choices=["1", "2", "3"], - help='difficulty cannot be parsed') + help='difficulty is not 1, 2, 3') parser.add_argument('lon', type=float, - help="lon cannot be parsed") + help="lon is not a float") parser.add_argument('lat', type=float, - help="lat cannot be parsed") + help="lat is not a float") + parser.add_argument('radius', type=int, + help="radius is not an int") + parser.add_argument('include_inactive', type=bool, default=False, + help="include_inactive it not bool") args = parser.parse_args() difficulty = None @@ -196,15 +169,11 @@ def get(self): # Try to get difficulty from argument, or users preference difficulty = args['difficulty'] - # for local challenges, first look at lon / lat passed in - if args.lon is not None and args.lat is not None: - contains = 'POINT(%s %s)' % (args.lon, args.lat) - # if there is none, look at the user's home location from OSM - # elif 'home_location' in session: - # contains = 'POINT(%s %s)' % tuple(session.get('home_location')) - # get the list of challenges meeting the criteria - query = db.session.query(Challenge).filter_by(active=True) + query = db.session.query(Challenge) + + if not args.include_inactive: + query = query.filter_by(active=True) if difficulty is not None: query = query.filter_by(difficulty=difficulty) @@ -234,6 +203,18 @@ def get(self): """Return information about the logged in user""" return marshal(session, me_fields) + def put(self): + """User setting information about themselves""" + try: + payload = json.loads(request.data) + except Exception: + abort(400) + [session.pop(k, None) for k, v in payload.iteritems() if v is None] + for k, v in payload.iteritems(): + if v is not None: + session[k] = v + return {} + class ApiChallengePolygon(ProtectedResource): @@ -252,9 +233,20 @@ class ApiStatsChallenge(ProtectedResource): def get(self, slug): """Return statistics for the challenge identified by 'slug'""" + # get the challenge challenge = get_challenge_or_404(slug, True) - total = len(challenge.tasks) - unfixed = challenge.approx_tasks_available + + # query the number of tasks + query = db.session.query(Task).filter_by(challenge_slug=challenge.slug) + # refine with the user defined editing area + query = refine_with_user_area(query) + # emit count + total = query.count() + + # get the approximate number of available tasks + unfixed = query.filter(Task.status.in_( + ['available', 'created', 'skipped'])).count() + return {'total': total, 'unfixed': unfixed} @@ -369,23 +361,22 @@ def get(self, slug): # If no tasks are found with this method, then this challenge # is complete if task is None: - # Send a mail to the challenge admin - requests.post( - "https://api.mailgun.net/v2/maproulette.org/messages", - auth=("api", app.config["MAILGUN_API_KEY"]), - data={"from": "MapRoulette ", - "to": ["maproulette@maproulette.org"], - "subject": - "Challenge {} is complete".format(challenge.slug), - "text": - "{challenge} has no remaining tasks" - " on server {server}".format( - challenge=challenge.title, - server=url_for('index', _external=True))}) - # Deactivate the challenge - challenge.active = False - db.session.add(challenge) - db.session.commit() + if not user_area_is_defined(): + # send email and deactivate challenge only when + # there are no more tasks for the entire challenge, + # not if the user has defined an area to work on. + subject = "Challenge {} is complete".format(challenge.slug) + body = "{challenge} has no remaining tasks" + " on server {server}".format( + challenge=challenge.title, + server=url_for('index', _external=True)) + send_email("maproulette@maproulette.org", subject, body) + + # Deactivate the challenge + challenge.active = False + db.session.add(challenge) + db.session.commit() + # Is this the right error? return osmerror("ChallengeComplete", "Challenge {} is complete".format(challenge.title)) @@ -448,7 +439,13 @@ def get(self, slug, identifier): """Returns the geometries for the task identified by 'identifier' from the challenge identified by 'slug'""" task = get_task_or_404(slug, identifier) - return task.geometries + geometries = [geojson.Feature( + geometry=g.geometry, + properties={ + 'selected': True, + 'osmid': g.osmid}) for g in task.geometries] + return geojson.FeatureCollection(geometries) + # Add all resources to the RESTful API api.add_resource(ApiPing, @@ -469,7 +466,7 @@ def get(self, slug, identifier): api.add_resource(ApiStatsUser, '/api/stats/users') # stats about the signed in user -api.add_resource(ApiMe, +api.add_resource(ApiStatsMe, '/api/stats/me') # task endpoints api.add_resource(ApiChallengeTask, @@ -520,12 +517,14 @@ def put(self, slug): payload.get('difficulty')) db.session.add(c) db.session.commit() + return {} def delete(self, slug): """delete a challenge""" challenge = get_challenge_or_404(slug) db.session.delete(challenge) db.session.commit() + return {}, 204 class AdminApiTaskStatuses(Resource): @@ -549,7 +548,7 @@ def put(self, slug, identifier): # Parse the posted data parse_task_json(json.loads(request.data), slug, identifier) - return {} + return {}, 201 def delete(self, slug, identifier): """Delete a task""" @@ -558,6 +557,7 @@ def delete(self, slug, identifier): task.append_action(Action('deleted')) db.session.add(task) db.session.commit() + return {}, 204 class AdminApiUpdateTasks(Resource): diff --git a/maproulette/helpers.py b/maproulette/helpers.py index 86de321..72b6759 100644 --- a/maproulette/helpers.py +++ b/maproulette/helpers.py @@ -5,8 +5,13 @@ from functools import wraps import json from maproulette import app -from shapely.geometry import MultiPoint, asShape +from shapely.geometry import MultiPoint, asShape, Point from random import random +from sqlalchemy.sql.expression import cast +from geoalchemy2.functions import ST_DWithin +from geoalchemy2.shape import from_shape +from geoalchemy2.types import Geography +import requests def signed_in(): @@ -122,6 +127,7 @@ def get_random_task(challenge): 'skipped', 'created']), Task.random >= rn).order_by(Task.random) + q = refine_with_user_area(q) if q.first() is None: # we may not have gotten one if there is no task with # Task.random <= the random value. chance of this gets @@ -133,7 +139,7 @@ def get_random_task(challenge): 'skipped', 'created']), Task.random < rn).order_by(Task.random) - + q = refine_with_user_area(q) return q.first() or None @@ -183,6 +189,32 @@ def get_envelope(geoms): return MultiPoint(geoms).envelope +def user_area_is_defined(): + return 'lon' and 'lat' and 'radius' in session + + +def refine_with_user_area(query): + """Takes a query and refines it with a spatial constraint + based on user setting""" + if 'lon' and 'lat' and 'radius' in session: + return query.filter(ST_DWithin( + cast(Task.location, Geography), + cast(from_shape(Point(session["lon"], session["lat"])), Geography), + session["radius"])) + else: + return query + + +def send_email(to, subject, text): + requests.post( + "https://api.mailgun.net/v2/maproulette.org/messages", + auth=("api", app.config["MAILGUN_API_KEY"]), + data={"from": "MapRoulette ", + "to": list(to), + "subject": subject, + "text": text}) + + class GeoPoint(object): """A geo-point class for use as a validation in the req parser""" diff --git a/maproulette/models.py b/maproulette/models.py index 1d08302..01c56c4 100644 --- a/maproulette/models.py +++ b/maproulette/models.py @@ -1,6 +1,6 @@ # """This file contains the SQLAlchemy ORM models""" -from sqlalchemy import create_engine, and_, or_ +from sqlalchemy import create_engine from sqlalchemy.orm import synonym from sqlalchemy.ext.hybrid import hybrid_property, hybrid_method from sqlalchemy.ext.declarative import declarative_base @@ -10,8 +10,7 @@ import random from datetime import datetime from maproulette import app -from flask import session -from shapely.geometry import Polygon +from shapely.geometry import Polygon, Point, MultiPoint import pytz # set up the ORM engine and database object @@ -183,17 +182,6 @@ def polygon(self, shape): polygon = synonym('geom', descriptor=polygon) - @property - def approx_tasks_available(self): - """Return the approximate number of tasks - available for this challenge.""" - - return len( - [t for t in self.tasks if t.status in [ - 'created', - 'skipped', - 'available']]) - @hybrid_property def islocal(self): """Returns the localness of a challenge (is it small)""" @@ -229,6 +217,8 @@ class Task(db.Model): nullable=False) manifest = db.Column( db.String) # not used for now + location = db.Column( + Geometry) geometries = db.relationship( "TaskGeometry", cascade='all,delete-orphan', @@ -272,81 +262,6 @@ def has_status(cls, statuses): statuses = [statuses] return cls.status.in_(statuses) - @hybrid_property - def is_available(self): - return self.has_status([ - 'available', - 'created', - 'skipped']) or (self.has_status([ - 'assigned', - 'editing']) and datetime.utcnow() - - app.config['TASK_EXPIRATION_THRESHOLD'] > - self.actions[-1].timestamp) - - # with statuses as (select distinct on (task_id) timestamp, - # status, task_id from actions order by task_id, id desc) select id, - # challenge_slug from tasks join statuses c on (id = task_id) - # where c.status in ('available','skipped','created') or (c.status in - # ('editing','assigned') and now() - c.timestamp > '1 hour'); - - @is_available.expression - def is_available(cls): - # the common table expression - current_actions = db.session.query(Action).distinct( - Action.task_id).order_by(Action.task_id).order_by( - Action.id.desc()).cte( - name="current_actions") - # before this time, a challenge is available even if it's - # 'assigned' or 'editing' - available_time = datetime.utcnow() -\ - app.config['TASK_EXPIRATION_THRESHOLD'] - res = cls.id.in_( - db.session.query(Task.id).join(current_actions).filter( - or_( - current_actions.c.status.in_([ - 'available', - 'skipped', - 'created']), - and_( - current_actions.c.status.in_([ - 'editing', - 'assigned']), - available_time > - current_actions.c.timestamp)) - )) - return res - - @property - def location(self): - """Returns the location for this task as a Shapely geometry. - This is meant to give the client a quick hint about where the - task is located without having to transfer and decode the entire - task geometry. In reality what we do is transmit the first - geometry we find for the task. This is then parsed into a single - representative lon/lat in the API by getting the first coordinate - of the geometry retrieved here. See also the PointField class in - the API code.""" - - if not hasattr(self, 'geometries') or len(self.geometries) == 0: - return 'POINT(0 0)' - else: - g = self.geometries[0].geom - return to_shape(g) - - @location.setter - def location(self, shape): - """Set the location for this task from a Shapely object""" - - self.geom = from_shape(shape) - - def append_action(self, action): - self.actions.append(action) - # duplicate the action status string in the tasks table to save lookups - self.status = action.status - db.session.commit() - # if action.status == 'fixed': - # self.validate_fixed() - def update(self, new_values, geometries, commit=True): """This updates a task based on a dict with new values""" for k, v in new_values.iteritems(): @@ -362,64 +277,40 @@ def update(self, new_values, geometries, commit=True): for geometry in geometries: self.geometries = geometries + + # set the location for this task, as a representative point of the + # combined geometries. + self.set_location() + db.session.merge(self) if commit: db.session.commit() return True - def validate_fixed(self): - from maproulette.oauth import get_latest_changeset - from maproulette.helpers import get_envelope - import iso8601 - - intersecting = False - timeframe = False - - # get the latest changeset - latest_changeset = get_latest_changeset(session.get('osm_id')) - - # check if the changeset bounding box covers the task geometries - sw = (float(latest_changeset.get('min_lon')), - float(latest_changeset.get('min_lat'))) - ne = (float(latest_changeset.get('max_lon')), - float(latest_changeset.get('max_lat'))) - envelope = get_envelope([ne, sw]) - app.logger.debug(envelope) - for geom in [to_shape(taskgeom.geom) - for taskgeom in self.geometries]: - if geom.intersects(envelope): - intersecting = True - break - - app.logger.debug('intersecting: %s ' % (intersecting,)) - - # check if the timestamp is between assigned and fixed - assigned_action = Action.query.filter_by( - task_id=self.id).filter_by( - user_id=session.get('osm_id')).filter_by( - status='assigned').first() - - # get assigned time in UTC - assigned_timestamp = assigned_action.timestamp - assigned_timestamp = assigned_timestamp.replace(tzinfo=pytz.utc) - - # get the timestamp when the changeset was closed in UTC - changeset_closed_timestamp = iso8601.parse_date( - latest_changeset.get('closed_at')).replace(tzinfo=pytz.utc) - - app.logger.debug(assigned_timestamp) - app.logger.debug(changeset_closed_timestamp) - app.logger.debug(datetime.now(pytz.utc)) - - timeframe = assigned_timestamp <\ - changeset_closed_timestamp <\ - datetime.now(pytz.utc) + app.config['MAX_CHANGESET_OFFSET'] - - if intersecting and timeframe: - app.logger.debug('validated') - self.append_action(Action('validated', session.get('osm_id'))) - else: - app.logger.debug('could not validate') + @property + def is_within(self, lon, lat, radius): + return self.location.intersects(Point(lon, lat).buffer(radius)) + + def append_action(self, action): + self.actions.append(action) + # duplicate the action status string in the tasks table to save lookups + self.status = action.status + db.session.commit() + + def set_location(self): + """Set the location of a task as a cheaply calculated + representative point of the combined geometries.""" + # set the location field, which is a representative point + # for the task's geometries + # first, get all individual coordinates for the geometries + coordinates = [] + for geometry in self.geometries: + coordinates.extend(list(to_shape(geometry.geom).coords)) + # then, set the location to a representative point + # (cheaper than centroid) + if len(coordinates) > 0: + self.location = from_shape(MultiPoint( + coordinates).representative_point(), srid=4326) class TaskGeometry(db.Model): diff --git a/maproulette/static/css/style.css b/maproulette/static/css/style.css index f5a95ed..b6db916 100755 --- a/maproulette/static/css/style.css +++ b/maproulette/static/css/style.css @@ -152,7 +152,7 @@ div#challenge_blurb { } /* the little stats section */ -div#stats { +div.smallmsg { font-size: x-small; color: rgb(100,100,100); } @@ -208,3 +208,23 @@ td.hidden { table.stats { padding: 4px; } + +/* react transitions */ + +.dialog-enter { + opacity: 0.01; + transition: opacity .5s ease-in; +} + +.dialog-enter.dialog-enter-active { + opacity: 1; +} + +.dialog-leave { + opacity: 1; + transition: opacity .5s ease-in; +} + +.dialog-leave.example-leave-active { + opacity: 0.01; +} diff --git a/maproulette/static/index.html b/maproulette/static/index.html new file mode 100644 index 0000000..f762525 --- /dev/null +++ b/maproulette/static/index.html @@ -0,0 +1 @@ +MapRoulette Maintenance>

MapRoulette is undergoing a bit of maintenance. Please check back soon! \ No newline at end of file diff --git a/maproulette/templates/index.html b/maproulette/templates/index.html index 6f743d9..2a41b80 100644 --- a/maproulette/templates/index.html +++ b/maproulette/templates/index.html @@ -21,7 +21,10 @@
Challenge statistics
-
This challenge has N/A tasks, and we still need to fix N/A of them.
+
This challenge has N/A tasks, and we still need to fix N/A of them.
+
THIS IS NOT AN ERROR q
I'M NOT SURE / SKIP w
diff --git a/migrations/versions/3115f24a7604_.py b/migrations/versions/3115f24a7604_.py new file mode 100644 index 0000000..39169df --- /dev/null +++ b/migrations/versions/3115f24a7604_.py @@ -0,0 +1,26 @@ +"""adding location column to tasks table + +Revision ID: 3115f24a7604 +Revises: 38fe795129a0 +Create Date: 2014-05-04 14:02:19.755081 + +""" + +# revision identifiers, used by Alembic. +revision = '3115f24a7604' +down_revision = '38fe795129a0' + +from alembic import op + + +def upgrade(): + # there is no way to tell alembic to add a geometry column the right way? + op.execute( + "SELECT AddGeometryColumn('tasks', 'location', 4326, 'POINT', 2)") + # there is no way to tell alembic that we want a GiST type index? + op.execute("CREATE INDEX idx_tasks_location ON tasks USING GIST(location)") + + +def downgrade(): + op.drop_index('idx_tasks_location', 'tasks') + op.drop_column('tasks', 'location') diff --git a/requirements.txt b/requirements.txt index 1b833e3..cd5412b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ Flask>=0.10.1 -Flask-RESTful>=0.2.0 +# Flask-RESTful>=0.2.0 +-e git://github.com/twilio/flask-restful.git#egg=flask-restful Flask-KVSession>=0.4 Flask-OAuthlib>=0.4.2 Flask-SQLAlchemy>=1.0