├── requirements.txt ├── www ├── static │ ├── images │ │ ├── toggle.png │ │ ├── marker-red.png │ │ ├── marker-green.png │ │ ├── marker-shadow.png │ │ └── toggle.svg │ ├── Control.MiniMap.min.css │ ├── profile.js │ ├── PermalinkAttribution.js │ ├── style.css │ ├── leaflet-hash.js │ ├── Bing.js │ ├── StreetViewButtons.js │ ├── Control.MiniMap.min.js │ └── audit.js ├── __init__.py ├── templates │ ├── admin.html │ ├── layout.html │ ├── index.html │ ├── profile.html │ ├── newproject.html │ ├── map.html │ ├── project.html │ ├── browse.html │ ├── table.html │ └── task.html ├── util.py ├── db.py └── audit.py ├── .gitignore ├── cf_audit.wsgi ├── run.py ├── README.md ├── config.py ├── update_features.py └── LICENSE /requirements.txt: -------------------------------------------------------------------------------- 1 | peewee==2.10.2 2 | flask>=0.11 3 | flask-Compress 4 | flask-OAuthlib 5 | -------------------------------------------------------------------------------- /www/static/images/toggle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/cf_audit/master/www/static/images/toggle.png -------------------------------------------------------------------------------- /www/static/images/marker-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/cf_audit/master/www/static/images/marker-red.png -------------------------------------------------------------------------------- /www/static/images/marker-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/cf_audit/master/www/static/images/marker-green.png -------------------------------------------------------------------------------- /www/static/images/marker-shadow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/cf_audit/master/www/static/images/marker-shadow.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | mockup/ 3 | venv/ 4 | config_local.py 5 | *.pyc 6 | *.db 7 | *.swp 8 | *.log 9 | *.backup 10 | *.sql 11 | *.gz 12 | -------------------------------------------------------------------------------- /www/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | app = Flask(__name__) 4 | app.config.from_object('config') 5 | 6 | try: 7 | from flask_compress import Compress 8 | Compress(app) 9 | except ImportError: 10 | pass 11 | 12 | import www.audit 13 | -------------------------------------------------------------------------------- /cf_audit.wsgi: -------------------------------------------------------------------------------- 1 | import os, sys 2 | BASE_DIR = os.path.abspath(os.path.dirname(__file__)) 3 | sys.path.insert(0, BASE_DIR) 4 | PYTHON = 'python2.7' 5 | VENV_DIR = os.path.join(BASE_DIR, 'venv', 'lib', PYTHON, 'site-packages') 6 | if os.path.exists(VENV_DIR): 7 | sys.path.insert(1, VENV_DIR) 8 | 9 | from www import app as application 10 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | BASE_DIR = os.path.abspath(os.path.dirname(__file__)) 5 | sys.path.insert(0, BASE_DIR) 6 | PYTHON = 'python2.7' 7 | VENV_DIR = os.path.join(BASE_DIR, 'venv', 'lib', PYTHON, 'site-packages') 8 | if os.path.exists(VENV_DIR): 9 | sys.path.insert(1, VENV_DIR) 10 | 11 | from www import app 12 | from www.db import migrate 13 | migrate() 14 | app.run(debug=True) 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OSM Conflation Audit 2 | 3 | This website takes a JSON output from [OSM Conflator](https://github.com/mapsme/osm_conflate) 4 | and presents logged-in users an interface for validating each imported point, one-by-one. 5 | It records any changes and produces a file that can be later feeded back to the Conflator. 6 | 7 | ## Author and License 8 | 9 | All this was written by Ilya Zverev for MAPS.ME. Published under Apache License 2.0. 10 | -------------------------------------------------------------------------------- /www/templates/admin.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Administration — {% endblock %} 3 | {% block content %} 4 |

Administration

5 |
6 | Type user ids to allow uploading projects:
7 |
8 |
9 | 10 |
11 |

Return

12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | BASE_DIR = os.path.abspath(os.path.dirname(__file__)) 3 | 4 | DEBUG = True 5 | 6 | DATABASE_URI = 'sqlite:///' + os.path.join(BASE_DIR, 'audit.db') 7 | # DATABASE_URI = 'postgresql://localhost/cf_audit' 8 | MAX_CONTENT_LENGTH = 16*1024*1024 9 | 10 | ADMINS = set([290271]) # Zverik 11 | 12 | # Override these (and anything else) in config_local.py 13 | OAUTH_KEY = '' 14 | OAUTH_SECRET = '' 15 | SECRET_KEY = 'sdkjfhsfljhsadf' 16 | MAPILLARY_CLIENT_ID = '' 17 | 18 | try: 19 | from config_local import * 20 | except ImportError: 21 | pass 22 | -------------------------------------------------------------------------------- /www/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}{% endblock %}Conflate Audit 5 | 6 | 7 | {% block header %}{% endblock %} 8 | 12 | 13 | 14 | {% block content %}{% endblock %} 15 | 16 | 17 | -------------------------------------------------------------------------------- /www/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |

Auditing Tool for OSM Conflator

4 | {% if admin %} 5 |

Create a project

6 | {% endif %} 7 |

Imports that need validating:

8 | 15 | {% if not user %} 16 |

Login to validate imports

17 | {% else %} 18 |

Your settings

19 | {% endif %} 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /www/templates/profile.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Profile — {% endblock %} 3 | {% block header %} 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% endblock %} 12 | {% block content %} 13 |

User Profile

14 |

Regions that you are most familiar with:

15 |
16 |
17 | 18 | 19 |
20 |

Return

21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /www/static/Control.MiniMap.min.css: -------------------------------------------------------------------------------- 1 | .leaflet-control-minimap{border:rgba(255,255,255,1) solid;box-shadow:0 1px 5px rgba(0,0,0,.65);border-radius:3px;background:#f8f8f9;transition:all .6s}.leaflet-control-minimap a{background-color:rgba(255,255,255,1);background-repeat:no-repeat;z-index:99999;transition:all .6s}.leaflet-control-minimap a.minimized-bottomright{-webkit-transform:rotate(180deg);transform:rotate(180deg);border-radius:0}.leaflet-control-minimap a.minimized-topleft{-webkit-transform:rotate(0deg);transform:rotate(0deg);border-radius:0}.leaflet-control-minimap a.minimized-bottomleft{-webkit-transform:rotate(270deg);transform:rotate(270deg);border-radius:0}.leaflet-control-minimap a.minimized-topright{-webkit-transform:rotate(90deg);transform:rotate(90deg);border-radius:0}.leaflet-control-minimap-toggle-display{background-image:url(images/toggle.svg);background-size:cover;position:absolute;border-radius:3px 0 0}.leaflet-oldie .leaflet-control-minimap-toggle-display{background-image:url(images/toggle.png)}.leaflet-control-minimap-toggle-display-bottomright{bottom:0;right:0}.leaflet-control-minimap-toggle-display-topleft{top:0;left:0;-webkit-transform:rotate(180deg);transform:rotate(180deg)}.leaflet-control-minimap-toggle-display-bottomleft{bottom:0;left:0;-webkit-transform:rotate(90deg);transform:rotate(90deg)}.leaflet-control-minimap-toggle-display-topright{top:0;right:0;-webkit-transform:rotate(270deg);transform:rotate(270deg)}.leaflet-oldie .leaflet-control-minimap{border:1px solid #999}.leaflet-oldie .leaflet-control-minimap a{background-color:#fff}.leaflet-oldie .leaflet-control-minimap a.minimized{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2)} -------------------------------------------------------------------------------- /www/static/images/toggle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /update_features.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | BASE_DIR = os.path.abspath(os.path.dirname(__file__)) 6 | sys.path.insert(0, BASE_DIR) 7 | PYTHON = 'python2.7' 8 | VENV_DIR = os.path.join(BASE_DIR, 'venv', 'lib', PYTHON, 'site-packages') 9 | if os.path.exists(VENV_DIR): 10 | sys.path.insert(1, VENV_DIR) 11 | 12 | import codecs 13 | import datetime 14 | import logging 15 | import json 16 | from www.db import Project, database 17 | from www.util import update_features, update_features_cache 18 | 19 | if len(sys.argv) < 3: 20 | print "Usage: {} []".format(sys.argv[0]) 21 | sys.exit(1) 22 | 23 | logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s', datefmt='%H:%M:%S') 24 | logging.info('Reading JSON files') 25 | 26 | if sys.argv[2] == '-': 27 | features = [] 28 | else: 29 | with codecs.open(sys.argv[2], 'r', 'utf-8') as f: 30 | features = json.load(f)['features'] 31 | 32 | audit = None 33 | if len(sys.argv) > 3: 34 | with codecs.open(sys.argv[3], 'r', 'utf-8') as f: 35 | audit = json.load(f) 36 | 37 | if not features and not audit: 38 | logging.error("No features read") 39 | sys.exit(2) 40 | 41 | try: 42 | project = Project.get(Project.name == sys.argv[1]) 43 | except Project.DoesNotExist: 44 | logging.error("No such project: %s", sys.argv[1]) 45 | sys.exit(2) 46 | 47 | logging.info('Updating features') 48 | 49 | proj_audit = json.loads(project.audit or '{}') 50 | if audit: 51 | proj_audit.update(audit) 52 | project.audit = json.dumps(proj_audit, ensure_ascii=False) 53 | project.updated = datetime.datetime.utcnow().date() 54 | 55 | with database.atomic(): 56 | update_features(project, features, proj_audit) 57 | 58 | logging.info('Updating the feature cache') 59 | update_features_cache(project) 60 | project.save() 61 | -------------------------------------------------------------------------------- /www/templates/newproject.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}{% if project.name %}Edit{% else %}Create{% endif %} a project — {% endblock %} 3 | {% block content %} 4 |

{% if project.name %}Update{% else %}Create{% endif %} a project

5 |
6 | 7 | Short name:
8 | Title:
9 | URL:
10 | Description:
11 |
12 |
13 | JSON:
14 | Audit:
15 |
16 |
17 |
18 |
19 |
20 |
21 | 22 |
23 | {% if project.id %} 24 |

Delete

25 | {% endif %} 26 |

Return

27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /www/static/profile.js: -------------------------------------------------------------------------------- 1 | var bboxesLayer; 2 | 3 | $(function() { 4 | map = L.map('map', {minZoom: 4, maxZoom: 15, editable: true}); 5 | L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { 6 | attribution: '© OpenStreetMap' 7 | }).addTo(map); 8 | 9 | L.BoxControl = L.Control.extend({ 10 | onAdd: function (map) { 11 | var container = L.DomUtil.create('div', 'leaflet-control leaflet-bar'), 12 | link = L.DomUtil.create('a', '', container); 13 | 14 | link.href = '#'; 15 | link.title = 'Create a new box'; 16 | link.innerHTML = '⬛'; 17 | L.DomEvent.on(link, 'click', L.DomEvent.stop) 18 | .on(link, 'click', function () { 19 | map.editTools.startRectangle(); 20 | }, this); 21 | 22 | return container; 23 | } 24 | }); 25 | map.addControl(new L.BoxControl({ position: 'topleft' })); 26 | 27 | function addDeletePopup(layer) { 28 | var btn = $(''); 29 | btn.click(function() { 30 | bboxesLayer.removeLayer(layer); 31 | }); 32 | layer.bindPopup(btn[0]); 33 | } 34 | 35 | bboxesLayer = L.featureGroup().addTo(map); 36 | map.setView([50, 10], 4); 37 | map.editTools.featuresLayer = bboxesLayer; 38 | 39 | var bboxes_str = $('#bboxes').val(), 40 | bboxes = bboxes_str.split(';'); 41 | for (var i = 0; i < bboxes.length; i++) { 42 | var c = bboxes[i].split(','); 43 | if (c.length == 4) { 44 | var rect = L.rectangle([[+c[0], +c[1]], [+c[2], +c[3]]]).addTo(bboxesLayer); 45 | addDeletePopup(rect); 46 | rect.enableEdit(); 47 | } 48 | } 49 | if (bboxesLayer.getLayers().length > 0) 50 | map.fitBounds(bboxesLayer.getBounds()); 51 | 52 | map.on('editable:drawing:end', function(e) { 53 | addDeletePopup(e.layer); 54 | updateBBoxes(); 55 | }); 56 | map.on('editable:dragend', updateBBoxes); 57 | bboxesLayer.on('layerremove', updateBBoxes); 58 | }); 59 | 60 | function updateBBoxes() { 61 | var boxes = []; 62 | bboxesLayer.eachLayer(function(box) { 63 | var b = box.getBounds(), 64 | c1 = b.getSouthWest(), 65 | c2 = b.getNorthEast(); 66 | boxes.push([c1.lat, c1.lng, c2.lat, c2.lng].join(',')); 67 | }); 68 | $('#bboxes').val(boxes.join(';')); 69 | console.log($('#bboxes').val()); 70 | } 71 | -------------------------------------------------------------------------------- /www/static/PermalinkAttribution.js: -------------------------------------------------------------------------------- 1 | /* 2 | * L.Control.Attribution that replaces OpenStreetMap links with permalinks. 3 | * Also can edd an edit link. 4 | * Replaces standard attribution control, because of https://github.com/Leaflet/Leaflet/issues/2177 5 | */ 6 | L.Control.StandardAttribution = L.Control.Attribution; 7 | L.Control.PermalinkAttribution = L.Control.Attribution.extend({ 8 | onAdd: function( map ) { 9 | var container = L.Control.StandardAttribution.prototype.onAdd.call(this, map); 10 | map.on('moveend', this._update, this); 11 | return container; 12 | }, 13 | 14 | onRemove: function( map ) { 15 | map.off('moveend', this._update); 16 | L.Control.StandardAttribution.prototype.onRemove.call(this, map); 17 | }, 18 | 19 | // copied from original class and slightly modified 20 | _update: function () { 21 | if (!this._map) { return; } 22 | 23 | var attribs = []; 24 | 25 | for (var i in this._attributions) { 26 | if (this._attributions[i]) { 27 | // make permalink for openstreetmap 28 | if( i.indexOf('/openstreetmap.org') > 0 || i.indexOf('/www.openstreetmap.org') > 0 ) { 29 | var permalink = 'http://www.openstreetmap.org/#map={zoom}/{lat}/{lon}'; 30 | i = i.replace(/(['"])http[^'"]+openstreetmap.org[^'"]*(['"])/, '$1' + permalink + '$2'); 31 | if( this._map.options.attributionEditLink ) { 32 | var editlink = permalink.replace('#', 'edit#'); 33 | i = i.replace(/(openstreetmap.org[^'"]*(['"])[^>]*>[^<]+<\/a>)/, '$1 (Edit)'); 34 | } 35 | } 36 | var latlng = this._map.getCenter(); 37 | i = i.replace(/\{zoom\}/g, this._map.getZoom()).replace(/\{lat\}/g, L.Util.formatNum(latlng.lat, 4)).replace(/\{lon\}/g, L.Util.formatNum(latlng.lng, 4)); 38 | attribs.push(i); 39 | } 40 | } 41 | 42 | var prefixAndAttribs = []; 43 | 44 | if (this.options.prefix) { 45 | prefixAndAttribs.push(this.options.prefix); 46 | } 47 | if (attribs.length) { 48 | prefixAndAttribs.push(attribs.join(', ')); 49 | } 50 | 51 | this._container.innerHTML = prefixAndAttribs.join(' | '); 52 | } 53 | }); 54 | 55 | L.control.permalinkAttribution = function( options ) { 56 | return new L.Control.PermalinkAttribution(options); 57 | }; 58 | 59 | L.Map.mergeOptions({ 60 | attributionEditLink: false 61 | }); 62 | 63 | L.Control.Attribution = L.Control.PermalinkAttribution; 64 | L.control.standardAttribution = L.control.attribution; 65 | L.control.attribution = L.control.permalinkAttribution; 66 | -------------------------------------------------------------------------------- /www/templates/map.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}{{ project.title }} — {% endblock %} 3 | {% block header %} 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 27 | 28 | 29 | {% endblock %} 30 | {% block content %} 31 |
32 | 46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /www/templates/project.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}{{ project.title }} — {% endblock %} 3 | {% block header %} 4 | 26 | {% endblock %} 27 | {% block content %} 28 |

{{ project.title }}

29 |
{{ desc | safe }}
30 | {% if project.url %}

Additional info →

{% endif %} 31 |

Browse points 32 | (on a map, 33 | as a table)

34 | {% if project.can_validate %} 35 |

Validate the import

36 | {% endif %} 37 | {% if admin %} 38 |

Edit, 39 | Download Audit, 40 | Audit for Source

41 | {% endif %} 42 |
43 |
    44 |
  • Total features: {{ project.feature_count }}
  • 45 |
  • Features to validate: {{ count }}
  • 46 |
  • Features looked at: {{ val1 }}
  • 47 |
  • Validated twice: {{ val2 }}
  • 48 |
  • Have corrections: {{ corrected }}
  • 49 |
  • To be ignored: {{ skipped }}
  • 50 |
51 | {% if has_skipped %} 52 |

Put skipped items back on your review list

53 | {% endif %} 54 | {% if regions %} 55 | Filter by region: 56 | 61 | {% endif %} 62 |

Return

63 | 69 | {% endblock %} 70 | -------------------------------------------------------------------------------- /www/templates/browse.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}{{ project.title }} — {% endblock %} 3 | {% block header %} 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 30 | 31 | 32 | {% endblock %} 33 | {% block content %} 34 |
35 |

← to the project{% if project.can_validate %}, edit this{% endif %}

36 |

{{ project.title }}

37 |

38 | Transparent marker is the point location. 39 |

40 |
41 | 42 |
43 |
44 |
45 | Remarks: 46 |
47 |

The last reviewer rejected this change with the verdict "".

48 |
49 | 50 | 51 | 52 |
53 |
54 |
55 |
56 | {% endblock %} 57 | -------------------------------------------------------------------------------- /www/static/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | #map1, #map2 { 8 | position: fixed; 9 | right: 0; 10 | left: 35%; 11 | height: 50%; 12 | } 13 | #map1 { 14 | top: 0; 15 | } 16 | #map2 { 17 | bottom: 0; 18 | } 19 | 20 | @media screen and (max-width: 600px) { 21 | .leaflet-control-minimap { display: none !important; } 22 | } 23 | 24 | #map1.bigmap { 25 | height: 100%; 26 | position: inherit; 27 | left: 0; 28 | } 29 | 30 | #popup { 31 | display: none; 32 | } 33 | 34 | #left { 35 | width: 35%; 36 | padding: 0.5em; 37 | font-size: 14px; 38 | font-family: sans-serif; 39 | margin-bottom: 250px; 40 | line-height: 1; 41 | } 42 | 43 | #buttons { 44 | position: fixed; 45 | background: white; 46 | bottom: 0; 47 | width: 35%; 48 | } 49 | 50 | p.toproject { 51 | font-size: 12px; 52 | margin: 0; 53 | } 54 | 55 | h1 { 56 | font-size: 26px; 57 | } 58 | 59 | .hint { 60 | margin-top: 0.5em; 61 | font-style: italic; 62 | color: green; 63 | } 64 | 65 | .last_action { 66 | font-style: italic; 67 | color: darkred; 68 | } 69 | 70 | .tags_wrapper { 71 | margin: 1em 0; 72 | overflow-x: auto; 73 | width: 95%; 74 | } 75 | 76 | .tags { 77 | border-collapse: collapse; 78 | } 79 | 80 | .tags tr { 81 | border: 1px solid lightgrey; 82 | border-bottom: none; 83 | } 84 | 85 | .tags tr.lower { 86 | border-top: none; 87 | } 88 | 89 | .tags th, tr.lower, tr.notagedit { 90 | border-bottom: 1px solid lightgrey; 91 | } 92 | 93 | .tags th { 94 | text-align: left; 95 | padding-right: 1em; 96 | } 97 | 98 | .tags .tagedit td { 99 | cursor: pointer; 100 | } 101 | 102 | .tags td { 103 | padding: 2px 0; 104 | } 105 | 106 | .tags td a { 107 | color: inherit; 108 | } 109 | 110 | .tags td.red { background-color: pink; } 111 | .tags td.green { background-color: lightgreen; } 112 | .tags td.yellow { background-color: yellow; } 113 | 114 | .tags .notset { 115 | color: lightgrey; 116 | font-style: italic; 117 | } 118 | 119 | .tags td.red .notset { color: grey; } 120 | 121 | td input { 122 | float: right; 123 | height: 1em; 124 | } 125 | 126 | #fixme { 127 | width: 90%; 128 | } 129 | 130 | #buttons button { 131 | display: inline-block; 132 | width: 95%; 133 | margin-bottom: 0.5em; 134 | border: solid 1px lightgrey; 135 | font-size: 26px; 136 | } 137 | 138 | #buttons #submit_reason { 139 | width: inherit; 140 | margin: 0; 141 | font-size: 16px; 142 | vertical-align: bottom; 143 | } 144 | 145 | #reason_box p { 146 | margin: 0; 147 | } 148 | 149 | #reason_box input { 150 | width: 75%; 151 | } 152 | 153 | #reason_box #create { 154 | background-color: lightgreen; 155 | margin-top: 0.5em; 156 | } 157 | 158 | #buttons .b_good { 159 | background-color: lightgreen; 160 | height: 4em; 161 | } 162 | 163 | .b_good:hover, .b_next:hover, .b_readonly:hover { 164 | font-weight: bold; 165 | } 166 | 167 | #buttons .b_bad { 168 | background-color: white; 169 | color: pink; 170 | } 171 | 172 | #buttons #bad_dup, #buttons #bad_nosuch { 173 | width: 47%; 174 | vertical-align: bottom; 175 | } 176 | 177 | #buttons .b_bad:hover { 178 | color: red; 179 | } 180 | 181 | #buttons .b_zoom { 182 | width: 47%; 183 | height: 3em; 184 | vertical-align: bottom; 185 | } 186 | -------------------------------------------------------------------------------- /www/templates/table.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}{{ project.title }} — {% endblock %} 3 | {% block header %} 4 | 19 | {% endblock %} 20 | {% block content %} 21 |

{{ project.title }}

22 |

This table shows new and modified tags on each object. Hover over a cell to see the original value. 23 | Click on an "Edit" link to validate the feature.

24 | 25 |
26 | 27 | 28 | 29 | {% for col in columns %} 30 | 31 | {% endfor %} 32 | 33 | {% for row in rows %} 34 | 35 | 42 | {% for col in columns %} 43 | {% if col in row.tags %} 44 | {# TODO: v.before on hover, cross v.after if not chosen #} 45 | 47 | {% else %} 48 | 49 | {% endif %} 50 | {% endfor %} 51 | 52 | {% endfor %} 53 |
 {{ col }}
36 | {% if project.can_validate %} 37 | Edit 38 | {% else %} 39 | View 40 | {% endif %} 41 | {% if not row.tags[col].accepted %}{% endif %} {{ row.tags[col].after }}{% if not row.tags[col].accepted %}{% endif %} 
54 |
55 | 56 | {% if pagination.pages > 1 %} 57 | 74 | {% endif %} 75 | 76 |

Legend:

77 |
    78 |
  • Added
  • 79 |
  • Changed
  • 80 |
  • Deleted
  • 81 |
82 | {% if show_validated %} 83 |

Hide validated objects

84 | {% else %} 85 |

Include validated objects in the list

86 | {% endif %} 87 |

Return

88 | {% endblock %} 89 | -------------------------------------------------------------------------------- /www/templates/task.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}{{ project.title }} — {% endblock %} 3 | {% block header %} 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 29 | 30 | {% endblock %} 31 | {% block content %} 32 |
33 |

← to the project, browse around

34 |

{{ project.title }}

35 |

The last reviewer rejected this change with the verdict "".

36 |

37 | You can move the marker to a better location. Imagery may be misaligned. 38 | Semi-transparent marker is the location from . 39 |

40 |
41 | 42 |
43 |
44 |
45 |

Value for the fixme tag if needed:
46 |

47 |
48 |
49 | Remarks: 50 |
51 |
52 | 53 | 54 | 55 | 56 |
57 |

Why?
58 | 59 | 61 |

62 | 63 |
64 |
65 |
66 |
67 | 68 | 69 | {% endblock %} 70 | -------------------------------------------------------------------------------- /www/static/leaflet-hash.js: -------------------------------------------------------------------------------- 1 | (function(window) { 2 | var HAS_HASHCHANGE = (function() { 3 | var doc_mode = window.documentMode; 4 | return ('onhashchange' in window) && 5 | (doc_mode === undefined || doc_mode > 7); 6 | })(); 7 | 8 | L.Hash = function(map) { 9 | this.onHashChange = L.Util.bind(this.onHashChange, this); 10 | 11 | if (map) { 12 | this.init(map); 13 | } 14 | }; 15 | 16 | L.Hash.parseHash = function(hash) { 17 | if(hash.indexOf('#') === 0) { 18 | hash = hash.substr(1); 19 | } 20 | var args = hash.split("/"); 21 | if (args.length == 3) { 22 | var zoom = parseInt(args[0], 10), 23 | lat = parseFloat(args[1]), 24 | lon = parseFloat(args[2]); 25 | if (isNaN(zoom) || isNaN(lat) || isNaN(lon)) { 26 | return false; 27 | } else { 28 | return { 29 | center: new L.LatLng(lat, lon), 30 | zoom: zoom 31 | }; 32 | } 33 | } else { 34 | return false; 35 | } 36 | }; 37 | 38 | L.Hash.formatHash = function(map) { 39 | var center = map.getCenter(), 40 | zoom = map.getZoom(), 41 | precision = Math.max(0, Math.ceil(Math.log(zoom) / Math.LN2)); 42 | 43 | return "#" + [zoom, 44 | center.lat.toFixed(precision), 45 | center.lng.toFixed(precision) 46 | ].join("/"); 47 | }, 48 | 49 | L.Hash.prototype = { 50 | map: null, 51 | lastHash: null, 52 | 53 | parseHash: L.Hash.parseHash, 54 | formatHash: L.Hash.formatHash, 55 | 56 | init: function(map) { 57 | this.map = map; 58 | 59 | // reset the hash 60 | this.lastHash = null; 61 | this.onHashChange(); 62 | 63 | if (!this.isListening) { 64 | this.startListening(); 65 | } 66 | }, 67 | 68 | removeFrom: function(map) { 69 | if (this.changeTimeout) { 70 | clearTimeout(this.changeTimeout); 71 | } 72 | 73 | if (this.isListening) { 74 | this.stopListening(); 75 | } 76 | 77 | this.map = null; 78 | }, 79 | 80 | onMapMove: function() { 81 | // bail if we're moving the map (updating from a hash), 82 | // or if the map is not yet loaded 83 | 84 | if (this.movingMap || !this.map._loaded) { 85 | return false; 86 | } 87 | 88 | var hash = this.formatHash(this.map); 89 | if (this.lastHash != hash) { 90 | location.replace(hash); 91 | this.lastHash = hash; 92 | } 93 | }, 94 | 95 | movingMap: false, 96 | update: function() { 97 | var hash = location.hash; 98 | if (hash === this.lastHash) { 99 | return; 100 | } 101 | var parsed = this.parseHash(hash); 102 | if (parsed) { 103 | this.movingMap = true; 104 | 105 | this.map.setView(parsed.center, parsed.zoom, {animate: false}); 106 | 107 | this.movingMap = false; 108 | } else { 109 | this.onMapMove(this.map); 110 | } 111 | }, 112 | 113 | // defer hash change updates every 100ms 114 | changeDefer: 100, 115 | changeTimeout: null, 116 | onHashChange: function() { 117 | // throttle calls to update() so that they only happen every 118 | // `changeDefer` ms 119 | if (!this.changeTimeout) { 120 | var that = this; 121 | this.changeTimeout = setTimeout(function() { 122 | that.update(); 123 | that.changeTimeout = null; 124 | }, this.changeDefer); 125 | } 126 | }, 127 | 128 | isListening: false, 129 | hashChangeInterval: null, 130 | startListening: function() { 131 | this.map.on("moveend", this.onMapMove, this); 132 | 133 | if (HAS_HASHCHANGE) { 134 | L.DomEvent.addListener(window, "hashchange", this.onHashChange); 135 | } else { 136 | clearInterval(this.hashChangeInterval); 137 | this.hashChangeInterval = setInterval(this.onHashChange, 50); 138 | } 139 | this.isListening = true; 140 | }, 141 | 142 | stopListening: function() { 143 | this.map.off("moveend", this.onMapMove, this); 144 | 145 | if (HAS_HASHCHANGE) { 146 | L.DomEvent.removeListener(window, "hashchange", this.onHashChange); 147 | } else { 148 | clearInterval(this.hashChangeInterval); 149 | } 150 | this.isListening = false; 151 | } 152 | }; 153 | L.hash = function(map) { 154 | return new L.Hash(map); 155 | }; 156 | L.Map.prototype.addHash = function() { 157 | this._hash = L.hash(this); 158 | }; 159 | L.Map.prototype.removeHash = function() { 160 | this._hash.removeFrom(); 161 | }; 162 | })(window); 163 | -------------------------------------------------------------------------------- /www/util.py: -------------------------------------------------------------------------------- 1 | from .db import Feature, Task 2 | from peewee import fn 3 | import json 4 | import hashlib 5 | 6 | 7 | def update_features(project, features, audit): 8 | curfeats = Feature.select(Feature).where(Feature.project == project) 9 | ref2feat = {f.ref: f for f in curfeats} 10 | if features: 11 | deleted = set(ref2feat.keys()) 12 | else: 13 | deleted = set() 14 | updated = set() 15 | minlat = minlon = 180.0 16 | maxlat = maxlon = -180.0 17 | for f in features: 18 | data = json.dumps(f, ensure_ascii=False, sort_keys=True) 19 | md5 = hashlib.md5() 20 | md5.update(data.encode('utf-8')) 21 | md5_hex = md5.hexdigest() 22 | 23 | coord = f['geometry']['coordinates'] 24 | if coord[0] < minlon: 25 | minlon = coord[0] 26 | if coord[0] > maxlon: 27 | maxlon = coord[0] 28 | if coord[1] < minlat: 29 | minlat = coord[1] 30 | if coord[1] > maxlat: 31 | maxlat = coord[1] 32 | 33 | if 'ref_id' in f['properties']: 34 | ref = f['properties']['ref_id'] 35 | else: 36 | ref = '{}{}'.format(f['properties']['osm_type'], f['properties']['osm_id']) 37 | 38 | update = False 39 | if ref in ref2feat: 40 | deleted.remove(ref) 41 | feat = ref2feat[ref] 42 | if feat.feature_md5 != md5_hex: 43 | update = True 44 | update = True 45 | else: 46 | feat = Feature(project=project, ref=ref) 47 | feat.validates_count = 0 48 | update = True 49 | 50 | f_audit = audit.get(ref) 51 | if f_audit: 52 | f_audit = json.dumps(f_audit, ensure_ascii=False, sort_keys=True) 53 | if f_audit != feat.audit: 54 | feat.audit = f_audit 55 | update = True 56 | 57 | if update: 58 | feat.feature = data 59 | feat.feature_md5 = md5_hex 60 | feat.lon = round(coord[0] * 1e7) 61 | feat.lat = round(coord[1] * 1e7) 62 | feat.action = f['properties']['action'][0] 63 | feat.region = f['properties'].get('region') 64 | if feat.validates_count > 0: 65 | feat.validates_count = 0 if not feat.audit else 1 66 | Task.delete().where(Task.feature == feat).execute() 67 | feat.save() 68 | updated.add(ref) 69 | 70 | if deleted: 71 | q = Feature.delete().where(Feature.ref << list(deleted)) 72 | q.execute() 73 | 74 | for ref, f_audit in audit.items(): 75 | if ref in ref2feat and ref not in updated: 76 | if not f_audit: 77 | f_audit = None 78 | else: 79 | f_audit = json.dumps(f_audit, ensure_ascii=False, sort_keys=True) 80 | feat = ref2feat[ref] 81 | if f_audit != feat.audit: 82 | feat.audit = f_audit 83 | if feat.validates_count == 0 and f_audit: 84 | feat.validates_count = 1 85 | feat.save() 86 | 87 | project.bbox = ','.join([str(x) for x in (minlon, minlat, maxlon, maxlat)]) 88 | project.feature_count = Feature.select().where(Feature.project == project).count() 89 | project.features_js = None 90 | if Feature.select(fn.Count(fn.Distinct(Feature.region))).where( 91 | Feature.project == project).scalar() <= 1: 92 | project.regional = False 93 | project.save() 94 | 95 | 96 | def update_audit(project): 97 | query = Feature.select(Feature.ref, Feature.audit).where( 98 | Feature.project == project, Feature.audit.is_null(False)).tuples() 99 | audit = {} 100 | for feat in query: 101 | if feat[1]: 102 | audit[feat[0]] = json.loads(feat[1]) 103 | data = json.dumps(audit, ensure_ascii=False) 104 | project.audit = data 105 | return data 106 | 107 | 108 | def update_features_cache(project): 109 | query = Feature.select(Feature.ref, Feature.lat, Feature.lon, Feature.action).where( 110 | Feature.project == project).tuples() 111 | features = [] 112 | for ref, lat, lon, action in query: 113 | features.append([ref, [lat/1e7, lon/1e7], action]) 114 | data = json.dumps(features, ensure_ascii=False) 115 | project.features_js = data 116 | return data 117 | -------------------------------------------------------------------------------- /www/static/Bing.js: -------------------------------------------------------------------------------- 1 | // source: https://github.com/shramov/leaflet-plugins/tree/master/layer/tile 2 | // author: Pavel Shramov 3 | // license: BSD 2-clause 4 | L.BingLayer = L.TileLayer.extend({ 5 | options: { 6 | subdomains: [0, 1, 2, 3], 7 | type: 'Aerial', 8 | attribution: 'Bing', 9 | culture: '' 10 | }, 11 | 12 | initialize: function(key, options) { 13 | L.Util.setOptions(this, options); 14 | 15 | this._key = key; 16 | this._url = null; 17 | this.meta = {}; 18 | this.loadMetadata(); 19 | }, 20 | 21 | tile2quad: function(x, y, z) { 22 | /* jshint bitwise: false */ 23 | var quad = ''; 24 | for (var i = z; i > 0; i--) { 25 | var digit = 0; 26 | var mask = 1 << (i - 1); 27 | if ((x & mask) !== 0) digit += 1; 28 | if ((y & mask) !== 0) digit += 2; 29 | quad = quad + digit; 30 | } 31 | return quad; 32 | }, 33 | 34 | getTileUrl: function(p) { 35 | var z = this._getZoomForUrl(); 36 | var subdomains = this.options.subdomains, 37 | s = this.options.subdomains[Math.abs((p.x + p.y) % subdomains.length)]; 38 | return this._url.replace('{subdomain}', s) 39 | .replace('{quadkey}', this.tile2quad(p.x, p.y, z)) 40 | .replace('http:', document.location.protocol) 41 | .replace('{culture}', this.options.culture); 42 | }, 43 | 44 | loadMetadata: function() { 45 | var _this = this; 46 | var cbid = '_bing_metadata_' + L.Util.stamp(this); 47 | window[cbid] = function (meta) { 48 | _this.meta = meta; 49 | window[cbid] = undefined; 50 | var e = document.getElementById(cbid); 51 | e.parentNode.removeChild(e); 52 | if (meta.errorDetails) { 53 | window.alert("Got metadata" + meta.errorDetails); 54 | return; 55 | } 56 | _this.initMetadata(); 57 | }; 58 | var url = document.location.protocol + "//dev.virtualearth.net/REST/v1/Imagery/Metadata/" + this.options.type + "?include=ImageryProviders&jsonp=" + cbid + "&key=" + this._key; 59 | var script = document.createElement("script"); 60 | script.type = "text/javascript"; 61 | script.src = url; 62 | script.id = cbid; 63 | document.getElementsByTagName("head")[0].appendChild(script); 64 | }, 65 | 66 | initMetadata: function() { 67 | var r = this.meta.resourceSets[0].resources[0]; 68 | this.options.subdomains = r.imageUrlSubdomains; 69 | this._url = r.imageUrl; 70 | this._providers = []; 71 | if (r.imageryProviders) { 72 | for (var i = 0; i < r.imageryProviders.length; i++) { 73 | var p = r.imageryProviders[i]; 74 | for (var j = 0; j < p.coverageAreas.length; j++) { 75 | var c = p.coverageAreas[j]; 76 | var coverage = {zoomMin: c.zoomMin, zoomMax: c.zoomMax, active: false}; 77 | var bounds = new L.LatLngBounds( 78 | new L.LatLng(c.bbox[0]+0.01, c.bbox[1]+0.01), 79 | new L.LatLng(c.bbox[2]-0.01, c.bbox[3]-0.01) 80 | ); 81 | coverage.bounds = bounds; 82 | coverage.attrib = p.attribution; 83 | this._providers.push(coverage); 84 | } 85 | } 86 | } 87 | this._update(); 88 | }, 89 | 90 | _update: function() { 91 | if (!this._url || !this._map) return; 92 | this._updateAttribution(); 93 | L.TileLayer.prototype._update.apply(this, []); 94 | }, 95 | 96 | _updateAttribution: function() { 97 | var bounds = this._map.getBounds(); 98 | var zoom = this._map.getZoom(); 99 | for (var i = 0; i < this._providers.length; i++) { 100 | var p = this._providers[i]; 101 | if ((zoom <= p.zoomMax && zoom >= p.zoomMin) && 102 | bounds.intersects(p.bounds)) { 103 | if (!p.active && this._map.attributionControl) 104 | this._map.attributionControl.addAttribution(p.attrib); 105 | p.active = true; 106 | } else { 107 | if (p.active && this._map.attributionControl) 108 | this._map.attributionControl.removeAttribution(p.attrib); 109 | p.active = false; 110 | } 111 | } 112 | }, 113 | 114 | onRemove: function(map) { 115 | for (var i = 0; i < this._providers.length; i++) { 116 | var p = this._providers[i]; 117 | if (p.active && this._map.attributionControl) { 118 | this._map.attributionControl.removeAttribution(p.attrib); 119 | p.active = false; 120 | } 121 | } 122 | L.TileLayer.prototype.onRemove.apply(this, [map]); 123 | } 124 | }); 125 | 126 | L.bingLayer = function (key, options) { 127 | return new L.BingLayer(key, options); 128 | }; 129 | 130 | if( !('layerList' in window) ) 131 | window.layerList = { list: {} }; 132 | window.layerList.list["Bing Satellite"] = "new L.BingLayer('{key:http://msdn.microsoft.com/en-us/library/ff428642.aspx}')"; 133 | -------------------------------------------------------------------------------- /www/static/StreetViewButtons.js: -------------------------------------------------------------------------------- 1 | L.StreetView = L.Control.extend({ 2 | options: { 3 | google: true, 4 | bing: true, 5 | yandex: true, 6 | mapillary: true, 7 | mapillaryId: null, 8 | mosatlas: true 9 | }, 10 | 11 | providers: [ 12 | ['google', 'GSV', 'Google Street View', false, 13 | 'https://www.google.com/maps?layer=c&cbll={lat},{lon}'], 14 | ['bing', 'Bing', 'Bing StreetSide', 15 | L.latLngBounds([[25, -168], [71.4, 8.8]]), 16 | 'https://www.bing.com/maps?cp={lat}~{lon}&lvl=19&style=x&v=2'], 17 | ['yandex', 'ЯП', 'Yandex Panoramas', 18 | L.latLngBounds([[35.6, 18.5], [72, 180]]), 19 | 'https://yandex.ru/maps/?panorama%5Bpoint%5D={lon},{lat}'], 20 | ['mapillary', 'Mplr', 'Mapillary Photos', false, 21 | 'https://a.mapillary.com/v3/images?client_id={id}&closeto={lon},{lat}&lookat={lon},{lat}'], 22 | ['mosatlas', 'Мос', 'Панорамы из Атласа Москвы', 23 | L.latLngBounds([[55.113, 36.708], [56.041, 38]]), 24 | 'http://atlas.mos.ru/?lang=ru&z=9&ll={lon}%2C{lat}&pp={lon}%2C{lat}'], 25 | ], 26 | 27 | onAdd: function(map) { 28 | this._container = L.DomUtil.create('div', 'leaflet-bar'); 29 | this._buttons = []; 30 | 31 | for (var i = 0; i < this.providers.length; i++) 32 | this._addProvider(this.providers[i]); 33 | 34 | map.on('moveend', function() { 35 | if (!this._fixed) 36 | this._update(map.getCenter()); 37 | }, this); 38 | this._update(map.getCenter()); 39 | return this._container; 40 | }, 41 | 42 | fixCoord: function(latlon) { 43 | this._update(latlon); 44 | this._fixed = true; 45 | }, 46 | 47 | releaseCoord: function() { 48 | this._fixed = false; 49 | this._update(this._map.getCenter()); 50 | }, 51 | 52 | _addProvider: function(provider) { 53 | if (!this.options[provider[0]]) 54 | return; 55 | if (provider[0] == 'mapillary' && !this.options.mapillaryId) 56 | return; 57 | var button = L.DomUtil.create('a'); 58 | button.innerHTML = provider[1]; 59 | button.title = provider[2]; 60 | button._bounds = provider[3]; 61 | button._template = provider[4]; 62 | button.href = '#'; 63 | button.target = 'streetview'; 64 | button.style.padding = '0 8px'; 65 | button.style.width = 'auto'; 66 | 67 | // Some buttons require complex logic 68 | if (provider[0] == 'mapillary') { 69 | button._needUrl = false; 70 | L.DomEvent.on(button, 'click', function(e) { 71 | if (button._href) { 72 | this._ajaxRequest( 73 | button._href.replace(/{id}/, this.options.mapillaryId), 74 | function(data) { 75 | if (data && data.features && data.features[0].properties) { 76 | var photoKey = data.features[0].properties.key, 77 | url = 'https://www.mapillary.com/map/im/{key}'.replace(/{key}/, photoKey); 78 | window.open(url, button.target); 79 | } 80 | } 81 | ); 82 | } 83 | return L.DomEvent.preventDefault(e); 84 | }, this); 85 | } else 86 | button._needUrl = true; 87 | 88 | // Overriding some of the leaflet styles 89 | button.style.display = 'inline-block'; 90 | button.style.border = 'none'; 91 | button.style.borderRadius = '0 0 0 0'; 92 | this._buttons.push(button); 93 | }, 94 | 95 | _update: function(center) { 96 | if (!center) 97 | return; 98 | var last; 99 | for (var i = 0; i < this._buttons.length; i++) { 100 | var b = this._buttons[i], 101 | show = !b._bounds || b._bounds.contains(center), 102 | vis = this._container.contains(b); 103 | 104 | if (show && !vis) { 105 | ref = last ? last.nextSibling : this._container.firstChild; 106 | this._container.insertBefore(b, ref); 107 | } else if (!show && vis) { 108 | this._container.removeChild(b); 109 | return; 110 | } 111 | last = b; 112 | 113 | var tmpl = b._template; 114 | tmpl = tmpl 115 | .replace(/{lon}/g, L.Util.formatNum(center.lng, 6)) 116 | .replace(/{lat}/g, L.Util.formatNum(center.lat, 6)); 117 | if (b._needUrl) 118 | b.href = tmpl; 119 | else 120 | b._href = tmpl; 121 | } 122 | }, 123 | 124 | _ajaxRequest: function(url, callback) { 125 | if (window.XMLHttpRequest === undefined) 126 | return; 127 | var req = new XMLHttpRequest(); 128 | req.open("GET", url); 129 | req.onreadystatechange = function() { 130 | if (req.readyState === 4 && req.status == 200) { 131 | var data = (JSON.parse(req.responseText)); 132 | callback(data); 133 | } 134 | }; 135 | req.send(); 136 | } 137 | }); 138 | 139 | L.streetView = function(options) { 140 | return new L.StreetView(options); 141 | } 142 | -------------------------------------------------------------------------------- /www/db.py: -------------------------------------------------------------------------------- 1 | from peewee import ( 2 | fn, Model, CharField, IntegerField, ForeignKeyField, 3 | TextField, FixedCharField, BooleanField, DateField 4 | ) 5 | from playhouse.migrate import ( 6 | migrate as peewee_migrate, 7 | SqliteMigrator, 8 | MySQLMigrator, 9 | PostgresqlMigrator 10 | ) 11 | from playhouse.db_url import connect 12 | import config 13 | import logging 14 | 15 | database = connect(config.DATABASE_URI) 16 | if 'mysql' in config.DATABASE_URI: 17 | fn_Random = fn.Rand 18 | else: 19 | fn_Random = fn.Random 20 | 21 | 22 | class BaseModel(Model): 23 | class Meta: 24 | database = database 25 | 26 | 27 | class User(BaseModel): 28 | uid = IntegerField(primary_key=True) 29 | admin = BooleanField(default=False) 30 | bboxes = TextField(null=True) 31 | 32 | 33 | class Project(BaseModel): 34 | name = CharField(max_length=32, index=True, unique=True) 35 | title = CharField(max_length=250) 36 | description = TextField() 37 | url = CharField(max_length=1000, null=True) 38 | feature_count = IntegerField() 39 | can_validate = BooleanField() 40 | hidden = BooleanField(default=False) 41 | bbox = CharField(max_length=60) 42 | updated = DateField() 43 | owner = ForeignKeyField(User, related_name='projects') 44 | overlays = TextField(null=True) 45 | audit = TextField(null=True) 46 | validate_modified = BooleanField(default=False) 47 | features_js = TextField(null=True) 48 | prop_sv = BooleanField(default=False) 49 | regional = BooleanField(default=False) 50 | 51 | 52 | class Feature(BaseModel): 53 | project = ForeignKeyField(Project, index=True, related_name='features', on_delete='CASCADE') 54 | ref = CharField(max_length=250, index=True) 55 | lat = IntegerField() # times 1e7 56 | lon = IntegerField() 57 | region = CharField(max_length=200, null=True, index=True) 58 | action = FixedCharField(max_length=1) 59 | feature = TextField() 60 | feature_md5 = FixedCharField(max_length=32) 61 | audit = TextField(null=True) 62 | validates_count = IntegerField(default=0) 63 | 64 | 65 | class Task(BaseModel): 66 | user = ForeignKeyField(User, index=True, related_name='tasks') 67 | feature = ForeignKeyField(Feature, index=True, on_delete='CASCADE') 68 | skipped = BooleanField(default=False) 69 | 70 | 71 | # ------------------------------ MIGRATION ------------------------------ 72 | 73 | 74 | LAST_VERSION = 4 75 | 76 | 77 | class Version(BaseModel): 78 | version = IntegerField() 79 | 80 | 81 | @database.atomic() 82 | def migrate(): 83 | database.create_tables([Version], safe=True) 84 | try: 85 | v = Version.select().get() 86 | except Version.DoesNotExist: 87 | database.create_tables([User, Project, Feature, Task]) 88 | v = Version(version=LAST_VERSION) 89 | v.save() 90 | 91 | if v.version >= LAST_VERSION: 92 | return 93 | 94 | if 'mysql' in config.DATABASE_URI: 95 | migrator = MySQLMigrator(database) 96 | elif 'sqlite' in config.DATABASE_URI: 97 | migrator = SqliteMigrator(database) 98 | else: 99 | migrator = PostgresqlMigrator(database) 100 | 101 | if v.version == 0: 102 | # Making a copy of Project.owner field, because it's not nullable 103 | # and we need to migrate a default value. 104 | admin = User.select(User.uid).where(User.uid == list(config.ADMINS)[0]).get() 105 | owner = ForeignKeyField(User, related_name='projects', to_field=User.uid, default=admin) 106 | 107 | peewee_migrate( 108 | migrator.add_column(User._meta.db_table, User.admin.db_column, User.admin), 109 | migrator.add_column(Project._meta.db_table, Project.owner.db_column, owner), 110 | migrator.add_column(Project._meta.db_table, Project.hidden.db_column, Project.hidden), 111 | migrator.add_column(Project._meta.db_table, Project.overlays.db_column, 112 | Project.overlays), 113 | migrator.add_column(Task._meta.db_table, Task.skipped.db_column, Task.skipped), 114 | migrator.drop_column(Project._meta.db_table, 'validated_count'), 115 | ) 116 | v.version = 1 117 | v.save() 118 | 119 | if v.version == 1: 120 | peewee_migrate( 121 | migrator.add_column(Project._meta.db_table, Project.validate_modified.db_column, 122 | Project.validate_modified), 123 | migrator.add_column(Project._meta.db_table, Project.audit.db_column, Project.audit), 124 | ) 125 | v.version = 2 126 | v.save() 127 | 128 | if v.version == 2: 129 | peewee_migrate( 130 | migrator.add_column(Project._meta.db_table, Project.features_js.db_column, 131 | Project.features_js), 132 | ) 133 | v.version = 3 134 | v.save() 135 | 136 | if v.version == 3: 137 | peewee_migrate( 138 | migrator.add_column(Project._meta.db_table, Project.regional.db_column, 139 | Project.regional), 140 | migrator.add_column(Project._meta.db_table, Project.prop_sv.db_column, Project.prop_sv), 141 | migrator.add_column(Feature._meta.db_table, Feature.region.db_column, Feature.region), 142 | ) 143 | v.version = 4 144 | v.save() 145 | 146 | logging.info('Migrated the database to version %s', v.version) 147 | if v.version != LAST_VERSION: 148 | raise ValueError('LAST_VERSION in db.py should be {}'.format(v.version)) 149 | -------------------------------------------------------------------------------- /www/static/Control.MiniMap.min.js: -------------------------------------------------------------------------------- 1 | (function(factory,window){if(typeof define==="function"&&define.amd){define(["leaflet"],factory)}else if(typeof exports==="object"){module.exports=factory(require("leaflet"))}if(typeof window!=="undefined"&&window.L){window.L.Control.MiniMap=factory(L);window.L.control.minimap=function(layer,options){return new window.L.Control.MiniMap(layer,options)}}})(function(L){var MiniMap=L.Control.extend({includes:L.Mixin.Events,options:{position:"bottomright",toggleDisplay:false,zoomLevelOffset:-5,zoomLevelFixed:false,centerFixed:false,zoomAnimation:false,autoToggleDisplay:false,minimized:false,width:150,height:150,collapsedWidth:19,collapsedHeight:19,aimingRectOptions:{color:"#ff7800",weight:1,clickable:false},shadowRectOptions:{color:"#000000",weight:1,clickable:false,opacity:0,fillOpacity:0},strings:{hideText:"Hide MiniMap",showText:"Show MiniMap"},mapOptions:{}},initialize:function(layer,options){L.Util.setOptions(this,options);this.options.aimingRectOptions.clickable=false;this.options.shadowRectOptions.clickable=false;this._layer=layer},onAdd:function(map){this._mainMap=map;this._container=L.DomUtil.create("div","leaflet-control-minimap");this._container.style.width=this.options.width+"px";this._container.style.height=this.options.height+"px";L.DomEvent.disableClickPropagation(this._container);L.DomEvent.on(this._container,"mousewheel",L.DomEvent.stopPropagation);var mapOptions={attributionControl:false,dragging:!this.options.centerFixed,zoomControl:false,zoomAnimation:this.options.zoomAnimation,autoToggleDisplay:this.options.autoToggleDisplay,touchZoom:this.options.centerFixed?"center":!this._isZoomLevelFixed(),scrollWheelZoom:this.options.centerFixed?"center":!this._isZoomLevelFixed(),doubleClickZoom:this.options.centerFixed?"center":!this._isZoomLevelFixed(),boxZoom:!this._isZoomLevelFixed(),crs:map.options.crs};mapOptions=L.Util.extend(this.options.mapOptions,mapOptions);this._miniMap=new L.Map(this._container,mapOptions);this._miniMap.addLayer(this._layer);this._mainMapMoving=false;this._miniMapMoving=false;this._userToggledDisplay=false;this._minimized=false;if(this.options.toggleDisplay){this._addToggleButton()}this._miniMap.whenReady(L.Util.bind(function(){this._aimingRect=L.rectangle(this._mainMap.getBounds(),this.options.aimingRectOptions).addTo(this._miniMap);this._shadowRect=L.rectangle(this._mainMap.getBounds(),this.options.shadowRectOptions).addTo(this._miniMap);this._mainMap.on("moveend",this._onMainMapMoved,this);this._mainMap.on("move",this._onMainMapMoving,this);this._miniMap.on("movestart",this._onMiniMapMoveStarted,this);this._miniMap.on("move",this._onMiniMapMoving,this);this._miniMap.on("moveend",this._onMiniMapMoved,this)},this));return this._container},addTo:function(map){L.Control.prototype.addTo.call(this,map);var center=this.options.centerFixed||this._mainMap.getCenter();this._miniMap.setView(center,this._decideZoom(true));this._setDisplay(this.options.minimized);return this},onRemove:function(map){this._mainMap.off("moveend",this._onMainMapMoved,this);this._mainMap.off("move",this._onMainMapMoving,this);this._miniMap.off("moveend",this._onMiniMapMoved,this);this._miniMap.removeLayer(this._layer)},changeLayer:function(layer){this._miniMap.removeLayer(this._layer);this._layer=layer;this._miniMap.addLayer(this._layer)},_addToggleButton:function(){this._toggleDisplayButton=this.options.toggleDisplay?this._createButton("",this._toggleButtonInitialTitleText(),"leaflet-control-minimap-toggle-display leaflet-control-minimap-toggle-display-"+this.options.position,this._container,this._toggleDisplayButtonClicked,this):undefined;this._toggleDisplayButton.style.width=this.options.collapsedWidth+"px";this._toggleDisplayButton.style.height=this.options.collapsedHeight+"px"},_toggleButtonInitialTitleText:function(){if(this.options.minimized){return this.options.strings.showText}else{return this.options.strings.hideText}},_createButton:function(html,title,className,container,fn,context){var link=L.DomUtil.create("a",className,container);link.innerHTML=html;link.href="#";link.title=title;var stop=L.DomEvent.stopPropagation;L.DomEvent.on(link,"click",stop).on(link,"mousedown",stop).on(link,"dblclick",stop).on(link,"click",L.DomEvent.preventDefault).on(link,"click",fn,context);return link},_toggleDisplayButtonClicked:function(){this._userToggledDisplay=true;if(!this._minimized){this._minimize()}else{this._restore()}},_setDisplay:function(minimize){if(minimize!==this._minimized){if(!this._minimized){this._minimize()}else{this._restore()}}},_minimize:function(){if(this.options.toggleDisplay){this._container.style.width=this.options.collapsedWidth+"px";this._container.style.height=this.options.collapsedHeight+"px";this._toggleDisplayButton.className+=" minimized-"+this.options.position;this._toggleDisplayButton.title=this.options.strings.showText}else{this._container.style.display="none"}this._minimized=true;this._onToggle()},_restore:function(){if(this.options.toggleDisplay){this._container.style.width=this.options.width+"px";this._container.style.height=this.options.height+"px";this._toggleDisplayButton.className=this._toggleDisplayButton.className.replace("minimized-"+this.options.position,"");this._toggleDisplayButton.title=this.options.strings.hideText}else{this._container.style.display="block"}this._minimized=false;this._onToggle()},_onMainMapMoved:function(e){if(!this._miniMapMoving){var center=this.options.centerFixed||this._mainMap.getCenter();this._mainMapMoving=true;this._miniMap.setView(center,this._decideZoom(true));this._setDisplay(this._decideMinimized())}else{this._miniMapMoving=false}this._aimingRect.setBounds(this._mainMap.getBounds())},_onMainMapMoving:function(e){this._aimingRect.setBounds(this._mainMap.getBounds())},_onMiniMapMoveStarted:function(e){if(!this.options.centerFixed){var lastAimingRect=this._aimingRect.getBounds();var sw=this._miniMap.latLngToContainerPoint(lastAimingRect.getSouthWest());var ne=this._miniMap.latLngToContainerPoint(lastAimingRect.getNorthEast());this._lastAimingRectPosition={sw:sw,ne:ne}}},_onMiniMapMoving:function(e){if(!this.options.centerFixed){if(!this._mainMapMoving&&this._lastAimingRectPosition){this._shadowRect.setBounds(new L.LatLngBounds(this._miniMap.containerPointToLatLng(this._lastAimingRectPosition.sw),this._miniMap.containerPointToLatLng(this._lastAimingRectPosition.ne)));this._shadowRect.setStyle({opacity:1,fillOpacity:.3})}}},_onMiniMapMoved:function(e){if(!this._mainMapMoving){this._miniMapMoving=true;this._mainMap.setView(this._miniMap.getCenter(),this._decideZoom(false));this._shadowRect.setStyle({opacity:0,fillOpacity:0})}else{this._mainMapMoving=false}},_isZoomLevelFixed:function(){var zoomLevelFixed=this.options.zoomLevelFixed;return this._isDefined(zoomLevelFixed)&&this._isInteger(zoomLevelFixed)},_decideZoom:function(fromMaintoMini){if(!this._isZoomLevelFixed()){if(fromMaintoMini){return this._mainMap.getZoom()+this.options.zoomLevelOffset}else{var currentDiff=this._miniMap.getZoom()-this._mainMap.getZoom();var proposedZoom=this._miniMap.getZoom()-this.options.zoomLevelOffset;var toRet;if(currentDiff>this.options.zoomLevelOffset&&this._mainMap.getZoom()this._lastMiniMapZoom){toRet=this._mainMap.getZoom()+1;this._miniMap.setZoom(this._miniMap.getZoom()-1)}else{toRet=this._mainMap.getZoom()}}else{toRet=proposedZoom}this._lastMiniMapZoom=this._miniMap.getZoom();return toRet}}else{if(fromMaintoMini){return this.options.zoomLevelFixed}else{return this._mainMap.getZoom()}}},_decideMinimized:function(){if(this._userToggledDisplay){return this._minimized}if(this.options.autoToggleDisplay){if(this._mainMap.getBounds().contains(this._miniMap.getBounds())){return true}return false}return this._minimized},_isInteger:function(value){return typeof value==="number"},_isDefined:function(value){return typeof value!=="undefined"},_onToggle:function(){L.Util.requestAnimFrame(function(){L.DomEvent.on(this._container,"transitionend",this._fireToggleEvents,this);if(!L.Browser.any3d){L.Util.requestAnimFrame(this._fireToggleEvents,this)}},this)},_fireToggleEvents:function(){L.DomEvent.off(this._container,"transitionend",this._fireToggleEvents,this);var data={minimized:this._minimized};this.fire(this._minimized?"minimize":"restore",data);this.fire("toggle",data)}});L.Map.mergeOptions({miniMapControl:false});L.Map.addInitHook(function(){if(this.options.miniMapControl){this.miniMapControl=(new MiniMap).addTo(this)}});return MiniMap},window); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | -------------------------------------------------------------------------------- /www/static/audit.js: -------------------------------------------------------------------------------- 1 | var map1, map2, marker1, marker2, smarker1, smarker2, feature, keys, lastView, defaultTitle, svButton; 2 | 3 | if (!String.prototype.startsWith) { 4 | String.prototype.startsWith = function(searchString, position){ 5 | position = position || 0; 6 | return this.substr(position, searchString.length) === searchString; 7 | }; 8 | } 9 | 10 | $(function() { 11 | map1 = L.map('map1', {minZoom: AP.readonly ? 4 : 15, maxZoom: 19, zoomControl: false, attributionControl: false}); 12 | L.control.permalinkAttribution().addTo(map1); 13 | map1.attributionControl.setPrefix(''); 14 | map1.setView([20, 5], 7, {animate: false}); 15 | 16 | var imageryLayers = { 17 | "OSM": L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { 18 | attribution: '© OpenStreetMap', maxZoom: 19 19 | }), 20 | "Bing": L.bingLayer("AqXL21QURkJrJz4m4-IJn2smkeX5KIYsdhiNIH97boShcUMagCnQPn3JMYZjFEoH", { 21 | type: "Aerial", maxZoom: 21 22 | }), 23 | 'Esri': L.tileLayer('https://services.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { 24 | attribution: 'Terms & Feedback', maxZoom: 22 25 | }), 26 | 'DG Std': L.tileLayer("https://{s}.tiles.mapbox.com/v4/digitalglobe.0a8e44ba/{z}/{x}/{y}.png?access_token=pk.eyJ1IjoiZGlnaXRhbGdsb2JlIiwiYSI6ImNqZGFrZ3pjczNpaHYycXFyMGo0djY3N2IifQ.90uebT4-ow1uqZKTUrf6RQ", { 27 | attribution: 'Terms & Feedback', maxZoom: 22 28 | }), 29 | 'DG Pr': L.tileLayer("https://{s}.tiles.mapbox.com/v4/digitalglobe.316c9a2e/{z}/{x}/{y}.png?access_token=pk.eyJ1IjoiZGlnaXRhbGdsb2JlIiwiYSI6ImNqZGFrZ2c2dzFlMWgyd2x0ZHdmMDB6NzYifQ.9Pl3XOO82ArX94fHV289Pg", { 30 | attribution: 'Terms & Feedback', maxZoom: 22 31 | }) 32 | }; 33 | imageryLayers['OSM'].addTo(map1); 34 | 35 | var miniMap; 36 | if ($('#map2').length && $('#map2').is(':visible')) { 37 | map2 = L.map('map2', {minZoom: AP.readonly ? 4 : 15, maxZoom: 19, zoomControl: false}); 38 | map2.attributionControl.setPrefix(''); 39 | map2.setView([20, 5], 7, {animate: false}); 40 | var miniLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { 41 | attribution: '© OpenStreetMap', maxZoom: 19 42 | }); 43 | miniMap = new L.Control.MiniMap(miniLayer, { 44 | position: 'topright', 45 | height: 100, 46 | zoomLevelOffset: -6, 47 | minimized: true 48 | }).addTo(map2); 49 | 50 | delete imageryLayers['OSM']; 51 | imageryLayers['Bing'].addTo(map2); 52 | 53 | var move = true; 54 | map1.on('move', function() { 55 | if (move) { 56 | move = false; 57 | map2.setView(map1.getCenter(), map1.getZoom(), { animate: false }); 58 | move = true; 59 | } 60 | }); 61 | map2.on('move', function() { 62 | if (move) { 63 | move = false; 64 | map1.setView(map2.getCenter(), map2.getZoom(), { animate: false }); 65 | move = true; 66 | } 67 | }); 68 | } 69 | 70 | if (!$('p.toproject').length) { 71 | var ProjectButton = L.Control.extend({ 72 | onAdd: function() { 73 | var container = L.DomUtil.create('div', 'leaflet-bar'), 74 | button = L.DomUtil.create('a', '', container); 75 | button.href = AP.projectUrl; 76 | button.innerHTML = '← to the project'; 77 | button.style.width = 'auto'; 78 | button.style.padding = '0 4px'; 79 | return container; 80 | } 81 | }); 82 | map1.addControl(new ProjectButton({ position: 'topleft' })); 83 | } 84 | 85 | L.control.zoom({position: map2 ? 'topright' : 'topleft'}).addTo(map1); 86 | L.control.layers(imageryLayers, {}, {collapsed: false, position: 'bottomright'}).addTo(map2 || map1); 87 | if (map2 && L.streetView) { 88 | svOptions = { position: 'bottomright' }; 89 | if (!AP.proprietarySV) 90 | svOptions.google = false; 91 | if (AP.mapillaryId) 92 | svOptions.mapillaryId = AP.mapillaryId; 93 | svButton = L.streetView(svOptions).addTo(map2); 94 | } 95 | var popups = $('#popup').length > 0; 96 | 97 | if (AP.readonly && features) { 98 | var fl = L.markerClusterGroup({ 99 | showCoverageOnHover: false, 100 | maxClusterRadius: function(zoom) { return zoom < 15 ? 40 : 10; } 101 | }), 102 | iconRed = new L.Icon({ 103 | iconUrl: AP.imagesPath + '/marker-red.png', 104 | shadowUrl: AP.imagesPath + '/marker-shadow.png', 105 | iconSize: [25, 41], 106 | iconAnchor: [12, 41], 107 | shadowSize: [41, 41] 108 | }), 109 | iconGreen = new L.Icon({ 110 | iconUrl: AP.imagesPath + '/marker-green.png', 111 | shadowUrl: AP.imagesPath + '/marker-shadow.png', 112 | iconSize: [25, 41], 113 | iconAnchor: [12, 41], 114 | shadowSize: [41, 41] 115 | }); 116 | for (var i = 0; i < features.length; i++) { 117 | var action = features[i][2], 118 | icon = action == 'c' ? iconGreen : (action == 'd' ? iconRed : new L.Icon.Default()), 119 | m = L.marker(features[i][1], {icon: icon}); 120 | m.ref = features[i][0]; 121 | if (!popups) { 122 | m.on('click', function(e) { 123 | querySpecific(e.target.ref); 124 | }); 125 | } else { 126 | m.bindPopup('... downloading ...'); 127 | m.on('popupopen', function(e) { 128 | queryForPopup(e.target); 129 | }); 130 | m.on('popupclose', hidePoint); 131 | } 132 | fl.addLayer(m); 133 | } 134 | map1.addLayer(fl); 135 | map1.fitBounds(fl.getBounds()); 136 | } 137 | 138 | defaultTitle = $('#title').html(); 139 | $('#hint').hide(); 140 | $('#last_action').hide(); 141 | $('#remarks_box').hide(); 142 | map1.on('zoomend', function() { 143 | if (map1.getZoom() >= 10) { 144 | if (AP.readonly) { 145 | $('#zoom_out').show(); 146 | $('#zoom_all').show(); 147 | } 148 | if (miniMap) 149 | miniMap._setDisplay(false); 150 | } else { 151 | if (AP.readonly) { 152 | hidePoint(); 153 | $('#zoom_out').hide(); 154 | $('#zoom_all').hide(); 155 | } 156 | if (miniMap) 157 | miniMap._setDisplay(true); 158 | } 159 | }); 160 | if (AP.readonly) { 161 | if ($('#editthis').length) 162 | $('#editthis').hide(); 163 | $('#zoom_out').click(function() { 164 | hidePoint(); 165 | if (lastView) { 166 | map1.setView(lastView[0], lastView[1]); 167 | lastView = null; 168 | } else if (map1.getZoom() >= 10) 169 | map1.zoomOut(5); 170 | }); 171 | $('#zoom_all').click(function() { 172 | hidePoint(); 173 | map1.fitBounds(fl.getBounds()); 174 | }); 175 | $('#random').click(function() { queryNext(); }); 176 | if (!popups) { 177 | window.addEventListener('popstate', function(e) { 178 | querySpecific(e.state); 179 | }); 180 | } 181 | 182 | var hash = L.hash ? L.hash(map1) : null; 183 | if (AP.forceRef) { 184 | if (popups) { 185 | fl.eachLayer(function(layer) { 186 | if (layer.ref == AP.forceRef) { 187 | fl.zoomToShowLayer(layer, function() { 188 | layer.openPopup(); 189 | }) 190 | } 191 | }); 192 | } else 193 | querySpecific(AP.forceRef); 194 | } else if (hash) 195 | hash.update(); 196 | } else { 197 | var $rb = $('#reason_box'); 198 | $rb.hide(); 199 | $('#bad').click(function() { 200 | $rb.show(); 201 | $('#bad').hide(); 202 | $('#skip').hide(); 203 | $('#reason').focus(); 204 | }); 205 | $('#reason').keypress(function(e) { 206 | if (e.which == 13) { 207 | $('#submit_reason').click(); 208 | return false; 209 | } 210 | }); 211 | $('#good').click({good: true}, submit); 212 | $('#submit_reason').click({good: false, msg: 'reason'}, submit); 213 | $('#create').click({good: false, create: true, msg: 'reason'}, submit); 214 | $('#bad_dup').click({good: false, msg: 'Duplicate'}, submit); 215 | $('#bad_nosuch').click({good: false, msg: 'Not there'}, submit); 216 | $('#skip').click({good: true, msg: 'skip'}, submit); 217 | 218 | if (AP.forceRef) 219 | querySpecific(AP.forceRef); 220 | else 221 | queryNext(); 222 | } 223 | }); 224 | 225 | function queryNext(audit) { 226 | $.ajax(AP.endpoint + '/feature/' + AP.projectId, { 227 | contentType: 'application/json', 228 | data: audit == null ? (AP.readonly ? {browse: 1} : null) : JSON.stringify(audit), 229 | method: audit ? 'POST' : 'GET', 230 | dataType: 'json', 231 | error: function(x,e,h) { window.alert('Ajax error. Please reload the page.\n'+e+'\n'+h); hidePoint(); }, 232 | success: function(data) { data.feature.ref = data.ref; displayPoint(data.feature, data.audit || {}); } 233 | }); 234 | } 235 | 236 | function querySpecific(ref) { 237 | $.ajax(AP.endpoint + '/feature/' + AP.projectId, { 238 | contentType: 'application/json', 239 | data: {ref: ref}, 240 | method: 'GET', 241 | dataType: 'json', 242 | error: function(x,e,h) { window.alert('Ajax error. Please reload the page.\n'+e+'\n'+h); hidePoint(); }, 243 | success: function(data) { data.feature.ref = data.ref; displayPoint(data.feature, data.audit || {}); } 244 | }); 245 | } 246 | 247 | function queryForPopup(target) { 248 | if (!target.isPopupOpen()) 249 | target.openPopup(); 250 | 251 | $.ajax(AP.endpoint + '/feature/' + AP.projectId, { 252 | contentType: 'application/json', 253 | data: {ref: target.ref}, 254 | method: 'GET', 255 | dataType: 'json', 256 | error: function(x,e,h) { window.alert('Ajax error. Please reload the page.\n'+e+'\n'+h); }, 257 | success: function(data) { 258 | data.feature.ref = data.ref; 259 | displayPoint(data.feature, data.audit || {}, true); 260 | if (target.isPopupOpen()) { 261 | target.setPopupContent($('#popup').html().replace(/id="[^"]+"/g, '')); 262 | } else 263 | hidePoint(); 264 | } 265 | }); 266 | } 267 | 268 | function setChanged(fast) { 269 | var $good = $('#good'); 270 | if (!fast) 271 | $good.text('Record changes'); 272 | else 273 | $good.text($.isEmptyObject(prepareAudit()) ? 'Good' : 'Record changes'); 274 | } 275 | 276 | function updateMarkers(data, audit, panMap) { 277 | var movePos = audit['move'], latlon, rlatlon, rIsOSM = false, 278 | coord = data['geometry']['coordinates'], 279 | props = data['properties'], 280 | canMove = !AP.readonly && (props['can_move'] || props['action'] == 'create'), 281 | refCoord = props['action'] == 'create' ? coord : props['ref_coords'], 282 | wereCoord = props['were_coords']; 283 | 284 | if (!movePos || !refCoord || movePos == 'osm') { 285 | if (movePos == 'osm' && wereCoord) 286 | latlon = L.latLng(wereCoord[1], wereCoord[0]); 287 | else 288 | latlon = L.latLng(coord[1], coord[0]); 289 | if (wereCoord && movePos != 'osm') { 290 | rlatlon = L.latLng(wereCoord[1], wereCoord[0]); 291 | rIsOSM = true; 292 | } else 293 | rlatlon = refCoord ? L.latLng(refCoord[1], refCoord[0]) : null; 294 | } else if (movePos == 'dataset' && refCoord) { 295 | latlon = L.latLng(refCoord[1], refCoord[0]); 296 | if (wereCoord) 297 | rlatlon = L.latLng(wereCoord[1], wereCoord[0]); 298 | else 299 | rlatlon = L.latLng(coord[1], coord[0]); 300 | rIsOSM = true; 301 | } else if (movePos.length == 2) { 302 | latlon = L.latLng(movePos[1], movePos[0]); 303 | rlatlon = L.latLng(coord[1], coord[0]); 304 | rIsOSM = !wereCoord && props['action'] != 'create'; 305 | } 306 | 307 | if (marker1) 308 | map1.removeLayer(marker1); 309 | if (marker2) 310 | map2.removeLayer(marker2); 311 | if (smarker1) 312 | map1.removeLayer(smarker1); 313 | if (smarker2) 314 | map2.removeLayer(smarker2); 315 | 316 | $('#hint').show(); 317 | if (rlatlon && (props['action'] != 'create' || movePos)) { 318 | var smTitle = rIsOSM ? 'OSM location' : 'External dataset location'; 319 | smarker1 = L.marker(rlatlon, {opacity: 0.4, title: smTitle, zIndexOffset: -100}).addTo(map1); 320 | if (map2) 321 | smarker2 = L.marker(rlatlon, {opacity: 0.4, title: smTitle, zIndexOffset: -100}).addTo(map2); 322 | $('#tr_which').text(rIsOSM ? 'OpenStreetMap' : 'external dataset'); 323 | $('#transparent').show(); 324 | if (panMap) 325 | map1.fitBounds([latlon, rlatlon], {maxZoom: 18}); 326 | } else { 327 | $('#transparent').hide(); 328 | if (panMap) 329 | map1.setView(latlon, 18); 330 | } 331 | 332 | if (svButton) 333 | svButton.fixCoord(latlon); 334 | 335 | var mTitle = rIsOSM ? 'New location after moving' : 'OSM object location', 336 | iconGreen = new L.Icon({ 337 | iconUrl: AP.imagesPath + '/marker-green.png', 338 | shadowUrl: AP.imagesPath + '/marker-shadow.png', 339 | iconSize: [25, 41], 340 | iconAnchor: [12, 41], 341 | shadowSize: [41, 41] 342 | }), 343 | mIcon = canMove ? iconGreen : new L.Icon.Default(); 344 | if (map2) 345 | marker2 = L.marker(latlon, {draggable: canMove, title: mTitle, icon: mIcon}).addTo(map2); 346 | if (!AP.readonly) { 347 | marker1 = L.marker(latlon, {draggable: canMove, title: mTitle, icon: mIcon}).addTo(map1); 348 | 349 | if (canMove) { 350 | $('#canmove').show(); 351 | 352 | var guideLayer = L.layerGroup(); 353 | L.marker(latlon).addTo(guideLayer); 354 | L.marker(rlatlon).addTo(guideLayer); 355 | if (movePos && movePos.length == 2) 356 | L.marker([refCoord[1], refCoord[0]]).addTo(guideLayer); 357 | 358 | marker1.snapediting = new L.Handler.MarkerSnap(map1, marker1, {snapDistance: 8}); 359 | marker1.snapediting.addGuideLayer(guideLayer); 360 | marker1.snapediting.enable(); 361 | 362 | marker1.on('dragend', function() { 363 | map1.panTo(marker1.getLatLng()); 364 | setChanged(); 365 | }); 366 | 367 | if (marker2) { 368 | marker2.snapediting = new L.Handler.MarkerSnap(map2, marker2, {snapDistance: 8}); 369 | marker2.snapediting.addGuideLayer(guideLayer); 370 | marker2.snapediting.enable(); 371 | 372 | var move = true; 373 | marker1.on('move', function () { 374 | if (move) { 375 | move = false; 376 | marker2.setLatLng(marker1.getLatLng()); 377 | move = true; 378 | } 379 | }); 380 | marker2.on('move', function () { 381 | if (move) { 382 | move = false; 383 | marker1.setLatLng(marker2.getLatLng()); 384 | move = true; 385 | } 386 | }); 387 | marker2.on('dragend', function() { 388 | map1.panTo(marker2.getLatLng()); 389 | setChanged(); 390 | }); 391 | } 392 | } else { 393 | $('#canmove').hide(); 394 | } 395 | } 396 | } 397 | 398 | function saveHistoryState(ref) { 399 | if (AP.readonly) { 400 | if (history.state != ref) { 401 | history.pushState(ref, ref + ' — ' + document.title, 402 | AP.browseTemplateUrl.replace('tmpl', encodeURIComponent(ref))); 403 | } 404 | } else { 405 | history.replaceState(ref, ref + ' — ' + document.title, 406 | AP.featureTemplateUrl.replace('tmpl', encodeURIComponent(ref))); 407 | } 408 | } 409 | 410 | function prepareSidebar(data, audit) { 411 | var ref = data.ref, props = data['properties'], 412 | remarks = props['remarks']; 413 | 414 | $('#good').text('Good'); 415 | 416 | if (AP.readonly) { 417 | var $editThis = $('#editthis'); 418 | if (map1.getZoom() <= 15) 419 | lastView = [map1.getCenter(), map1.getZoom()]; 420 | if ($editThis.length) { 421 | $('#editlink').attr('href', AP.featureTemplateUrl.replace('tmpl', encodeURIComponent(data.ref))); 422 | $editThis.show(); 423 | } 424 | } else { 425 | $('#browselink').attr('href', AP.browseTemplateUrl.replace('tmpl', encodeURIComponent(data.ref))); 426 | } 427 | 428 | function formatObjectRef(props) { 429 | return ' a ' + 431 | (props['osm_type'] == 'node' ? 'point' : 'polygon') + ''; 432 | } 433 | 434 | var title; 435 | if (props['action'] == 'create') 436 | title = 'Create new node'; 437 | else if (props['action'] == 'delete') 438 | title = 'Delete' + formatObjectRef(props); 439 | else if (props['were_coords']) 440 | title = 'Modify and move' + formatObjectRef(props); 441 | else if (props['ref_coords']) 442 | title = 'Update tags on' + formatObjectRef(props); 443 | else 444 | title = 'Mark' + formatObjectRef(props) + ' as obsolete'; 445 | $('#title').html(title); 446 | 447 | $('#buttons button').each(function() { $(this).prop('disabled', false); }); 448 | if (AP.readonly) { 449 | // TODO: show or hide "zoom" buttons depending on selected feature 450 | } else if (props['action'] == 'create') { 451 | $('#bad').hide(); 452 | $('#bad_dup').show(); 453 | $('#bad_nosuch').show(); 454 | $('#skip').show(); 455 | $('#good').focus(); 456 | } else { 457 | $('#bad').show(); 458 | $('#bad_dup').hide(); 459 | $('#bad_nosuch').hide(); 460 | $('#skip').show(); 461 | $('#good').focus(); 462 | } 463 | 464 | if (!AP.readonly) { 465 | $('#fixme_box').show(); 466 | $('#fixme').val(audit['fixme'] || ''); 467 | $('#fixme').on('input', function() { setChanged(true); }); 468 | $('#reason').val(audit['comment'] || ''); 469 | } 470 | 471 | var verdict = audit['comment'] || ''; 472 | if (audit['create']) 473 | verdict = 'create new point instead'; 474 | if (audit['skip'] && !verdict) 475 | verdict = ''; 476 | if (verdict) { 477 | $('#last_verdict').text(verdict); 478 | $('#last_action').show(); 479 | } else { 480 | $('#last_action').hide(); 481 | } 482 | 483 | // render remarks, if any. 484 | if (remarks) { 485 | $('#remarks_box').show(); 486 | $('#remarks_content').text(remarks); 487 | } else { 488 | $('#remarks_box').hide(); 489 | } 490 | } 491 | 492 | function renderTagTable(data, audit, editNewTags) { 493 | var props = data['properties']; 494 | 495 | // Table of tags. First record the original values for unused tags 496 | var original = {}; 497 | for (var key in props) 498 | if (key.startsWith('tags.')) 499 | original[key.substr(key.indexOf('.')+1)] = props[key]; 500 | 501 | // Now prepare a list of [key, osm_value, new_value, is_changed] 502 | keys = []; 503 | var skip = {}; 504 | for (var key in props) { 505 | if (key.startsWith('tags') || key.startsWith('ref_unused_tags')) { 506 | k = key.substr(key.indexOf('.')+1); 507 | if (!AP.readonly && (k == 'source' || k.startsWith('ref') || k == 'fixme') && !key.startsWith('ref_unused')) { 508 | if (k == 'fixme') 509 | $('#fixme').val(props[key]); 510 | keys.push([k, props[key]]); 511 | skip[k] = true; 512 | } 513 | else if (key.startsWith('tags_new.')) 514 | keys.push([k, '', props[key], true]); 515 | else if (key.startsWith('tags_deleted.')) 516 | keys.push([k, props[key], '', true]); 517 | else if (key.startsWith('ref_unused')) { 518 | keys.push([k, original[k], props[key], false]); 519 | skip[k] = true; 520 | } else if (key.startsWith('tags_changed.')) { 521 | var i = props[key].indexOf(' -> '); 522 | keys.push([k, props[key].substr(0, i), props[key].substr(i+4), true]); 523 | } else if (key.startsWith('tags.')) { 524 | if (editNewTags && props['action'] == 'create') 525 | keys.push([k, '', props[key], true]); 526 | else if (!skip[k]) 527 | keys.push([k, props[key]]); 528 | } 529 | } 530 | } 531 | 532 | // Apply audit data 533 | for (var i = 0; i < keys.length; i++) { 534 | if (keys[i].length == 4) { 535 | if (audit['keep'] && audit['keep'].indexOf(keys[i][0]) >= 0) 536 | keys[i].push(false); 537 | else if (audit['override'] && audit['override'].indexOf(keys[i][0]) >= 0) 538 | keys[i].push(true); 539 | else 540 | keys[i].push(keys[i][3]); 541 | } 542 | } 543 | 544 | // Render the table 545 | function esc(s) { 546 | s = s.replace(/&/g, "&").replace(//g, ">"); 547 | if (s.startsWith('http://') || s.startsWith('https://')) 548 | s = ''+s+''; 549 | return s; 550 | } 551 | keys.sort(function(a, b) { 552 | return a.length == b.length ? ((a[0] > b[0]) - (b[0] - a[0])) : a.length - b.length; 553 | }); 554 | 555 | var rows = '', notset = 'not set'; 556 | for (var i = 0; i < keys.length; i++) { 557 | key = keys[i]; 558 | if (key.length == 2) 559 | rows += '' + esc(key[0]) + '' + esc(key[1]) + ''; 560 | else { 561 | rows += '' + esc(key[0]) + ''; 562 | rows += '' + (!key[1] ? notset : esc(key[1])) + ' '; 563 | rows += '' + (!key[2] ? notset : esc(key[2])) + ' '; 564 | } 565 | } 566 | $('#tags').empty().append(rows); 567 | 568 | // Set state of each row 569 | function cellColor(row, which) { 570 | if (which == 1) 571 | return row[1] == '' ? 'red' : 'yellow'; 572 | if (which == 2) 573 | return row[2] == '' ? 'yellow' : 'green'; 574 | return 'green'; 575 | } 576 | 577 | var rowidx_to_td = {}; 578 | $('#tags td').each(function() { 579 | var $radio = $(this).children('input'); 580 | if (!$radio.length) 581 | return; 582 | var idx = +$radio.val().substr(2), 583 | which = +$radio.val()[0], 584 | row = keys[idx], 585 | selected = (which == 1 && !row[4]) || (which == 2 && row[4]); 586 | if (!rowidx_to_td[idx]) 587 | rowidx_to_td[idx] = [null, null]; 588 | rowidx_to_td[idx][which] = $(this); 589 | if (selected) { 590 | $radio.prop('checked', true); 591 | $(this).addClass(cellColor(row, which)); 592 | } 593 | if (AP.readonly) 594 | $radio.hide(); 595 | else { 596 | $(this).click(function() { 597 | $radio.prop('checked', true); 598 | $(this).addClass(cellColor(row, which)); 599 | rowidx_to_td[idx][3-which].children('input').prop('checked', false); 600 | rowidx_to_td[idx][3-which].removeClass(cellColor(row, 3-which)); 601 | keys[idx][4] = which == 2; 602 | setChanged(); 603 | }); 604 | } 605 | }); 606 | } 607 | 608 | function displayPoint(data, audit, forPopup) { 609 | if (!data.ref) { 610 | window.alert('Received an empty feature. You must have validated all of them.'); 611 | hidePoint(); 612 | return; 613 | } 614 | feature = data; 615 | if (!forPopup) 616 | saveHistoryState(data.ref); 617 | updateMarkers(data, audit, !forPopup); 618 | prepareSidebar(data, audit); 619 | renderTagTable(data, audit, !forPopup); 620 | } 621 | 622 | function hidePoint() { 623 | $('#tags').empty(); 624 | $('#hint').hide(); 625 | $('#last_action').hide(); 626 | $('#fixme_box').hide(); 627 | $('#remarks_box').hide(); 628 | $('#title').html(defaultTitle); 629 | if (marker2) 630 | map2.removeLayer(marker2); 631 | if (smarker1) 632 | map1.removeLayer(smarker1); 633 | if (smarker2) 634 | map2.removeLayer(smarker2); 635 | if (svButton) 636 | svButton.releaseCoord(); 637 | } 638 | 639 | function prepareAudit(data) { 640 | var fixme = $('#fixme').val(), 641 | audit = {}, 642 | maxd = 3, // max distance to register change in meters 643 | coord = feature['geometry']['coordinates'], 644 | osmCoord = feature['properties']['were_coords'], 645 | dataCoord = feature['properties']['ref_coords'], 646 | newCoordTmp = marker1.getLatLng(), 647 | newCoord = [L.Util.formatNum(newCoordTmp.lng, 7), L.Util.formatNum(newCoordTmp.lat, 7)]; 648 | 649 | // Record movement 650 | function distance(c1, c2) { 651 | if (!c2) 652 | return 1000000; 653 | var rad = Math.PI / 180, 654 | lat1 = c1[1] * rad, 655 | lat2 = c2[1] * rad, 656 | a = Math.sin(lat1) * Math.sin(lat2) + 657 | Math.cos(lat1) * Math.cos(lat2) * Math.cos((c2[0] - c1[0]) * rad); 658 | 659 | return 6371000 * Math.acos(Math.min(a, 1)); 660 | } 661 | if (distance(newCoord, coord) > maxd) { 662 | if (distance(newCoord, osmCoord) < maxd) 663 | audit['move'] = 'osm'; 664 | else if (distance(newCoord, dataCoord) < maxd) 665 | audit['move'] = 'dataset'; 666 | else 667 | audit['move'] = newCoord; 668 | } 669 | 670 | // Record tag changes 671 | for (var i = 0; i < keys.length; i++) { 672 | if (keys[i][3] != keys[i][4]) { 673 | if (keys[i][4]) { 674 | if (!audit['override']) 675 | audit['override'] = [] 676 | audit['override'].push(keys[i][0]); 677 | } else { 678 | if (!audit['keep']) 679 | audit['keep'] = [] 680 | audit['keep'].push(keys[i][0]); 681 | } 682 | } 683 | } 684 | 685 | // Record fixme 686 | if (fixme) 687 | audit['fixme'] = fixme; 688 | 689 | // Record good/bad and comment 690 | if (data && !data.good) { 691 | if (data.create) 692 | audit['create'] = true; 693 | else 694 | audit['skip'] = true; 695 | if (data.msg) 696 | audit['comment'] = data.msg == 'reason' ? ($('#reason').val() || '') : data.msg; 697 | } 698 | 699 | return audit; 700 | } 701 | 702 | function submit(e) { 703 | // Send audit result and query the next feature 704 | var audit = prepareAudit(e.data); 705 | console.log(JSON.stringify(audit)); 706 | $('#reason_box').hide(); 707 | $('#buttons button').each(function() { $(this).prop('disabled', true); }); 708 | queryNext([feature.ref, e.data.msg == 'skip' ? null : audit]); 709 | } 710 | -------------------------------------------------------------------------------- /www/audit.py: -------------------------------------------------------------------------------- 1 | from www import app 2 | from .db import database, User, Feature, Project, Task, fn_Random 3 | from .util import update_features, update_audit, update_features_cache 4 | from flask import session, url_for, redirect, request, render_template, flash, jsonify 5 | from flask_oauthlib.client import OAuth 6 | from peewee import fn, OperationalError 7 | import json 8 | import config 9 | import codecs 10 | import datetime 11 | import math 12 | import os 13 | 14 | oauth = OAuth() 15 | openstreetmap = oauth.remote_app( 16 | 'OpenStreetMap', 17 | base_url='https://api.openstreetmap.org/api/0.6/', 18 | request_token_url='https://www.openstreetmap.org/oauth/request_token', 19 | access_token_url='https://www.openstreetmap.org/oauth/access_token', 20 | authorize_url='https://www.openstreetmap.org/oauth/authorize', 21 | consumer_key=app.config['OAUTH_KEY'] or '123', 22 | consumer_secret=app.config['OAUTH_SECRET'] or '123' 23 | ) 24 | 25 | 26 | @app.before_request 27 | def before_request(): 28 | database.connect() 29 | 30 | 31 | @app.teardown_request 32 | def teardown(exception): 33 | if not database.is_closed(): 34 | database.close() 35 | 36 | 37 | def dated_url_for(endpoint, **values): 38 | if endpoint == 'static': 39 | filename = values.get('filename', None) 40 | if filename: 41 | file_path = os.path.join(app.root_path, 42 | endpoint, filename) 43 | values['q'] = int(os.stat(file_path).st_mtime) 44 | return url_for(endpoint, **values) 45 | app.jinja_env.globals['dated_url_for'] = dated_url_for 46 | 47 | 48 | def get_user(): 49 | if 'osm_uid' in session: 50 | try: 51 | return User.get(User.uid == session['osm_uid']) 52 | except User.DoesNotExist: 53 | # Logging user out 54 | if 'osm_token' in session: 55 | del session['osm_token'] 56 | if 'osm_uid' in session: 57 | del session['osm_uid'] 58 | return None 59 | 60 | 61 | def is_admin(user, project=None): 62 | if not user: 63 | return False 64 | if user.uid in config.ADMINS: 65 | return True 66 | if not project: 67 | return user.admin 68 | return user == project.owner 69 | 70 | 71 | @app.route('/') 72 | def front(): 73 | user = get_user() 74 | projects = Project.select().order_by(Project.updated.desc()) 75 | 76 | def local_is_admin(proj): 77 | return is_admin(user, proj) 78 | return render_template('index.html', user=user, projects=projects, 79 | admin=is_admin(user), is_admin=local_is_admin) 80 | 81 | 82 | @app.route('/robots.txt') 83 | def robots(): 84 | return app.response_class('User-agent: *\nDisallow: /', mimetype='text/plain') 85 | 86 | 87 | @app.route('/login') 88 | def login(): 89 | if 'osm_token' not in session: 90 | session['objects'] = request.args.get('objects') 91 | if request.args.get('next'): 92 | session['next'] = request.args.get('next') 93 | return openstreetmap.authorize(callback=url_for('oauth')) 94 | return redirect(url_for('front')) 95 | 96 | 97 | @app.route('/oauth') 98 | def oauth(): 99 | resp = openstreetmap.authorized_response() 100 | if resp is None: 101 | return 'Denied. Try again.' 102 | session['osm_token'] = ( 103 | resp['oauth_token'], 104 | resp['oauth_token_secret'] 105 | ) 106 | user_details = openstreetmap.get('user/details').data 107 | uid = int(user_details[0].get('id')) 108 | session['osm_uid'] = uid 109 | try: 110 | User.get(User.uid == uid) 111 | except User.DoesNotExist: 112 | User.create(uid=uid) 113 | 114 | if session.get('next'): 115 | redir = session['next'] 116 | del session['next'] 117 | else: 118 | redir = url_for('front') 119 | return redirect(redir) 120 | 121 | 122 | @openstreetmap.tokengetter 123 | def get_token(token='user'): 124 | if token == 'user' and 'osm_token' in session: 125 | return session['osm_token'] 126 | return None 127 | 128 | 129 | @app.route('/logout') 130 | def logout(): 131 | if 'osm_token' in session: 132 | del session['osm_token'] 133 | if 'osm_uid' in session: 134 | del session['osm_uid'] 135 | return redirect(url_for('front')) 136 | 137 | 138 | @app.route('/project/') 139 | @app.route('/project//') 140 | @app.route('/project//') 141 | def project(name, region=None): 142 | project = Project.get(Project.name == name) 143 | desc = project.description.replace('\n', '
') 144 | cnt = Feature.select(Feature.id).where(Feature.project == project) 145 | val1 = Feature.select(Feature.id).where(Feature.project == project, 146 | Feature.validates_count > 0) 147 | val2 = Feature.select(Feature.id).where(Feature.project == project, 148 | Feature.validates_count >= 2) 149 | corrected = Feature.select(Feature.id).where( 150 | Feature.project == project, Feature.audit.is_null(False), Feature.audit != '') 151 | skipped = Feature.select(Feature.id).where( 152 | Feature.project == project, Feature.audit.contains('"skip": true')) 153 | 154 | if region is not None: 155 | val1 = val1.where(Feature.region == region) 156 | val2 = val2.where(Feature.region == region) 157 | cnt = cnt.where(Feature.region == region) 158 | corrected = corrected.where(Feature.region == region) 159 | skipped = skipped.where(Feature.region == region) 160 | if project.validate_modified: 161 | val1 = val1.where(Feature.action == 'm') 162 | val2 = val2.where(Feature.action == 'm') 163 | cnt = cnt.where(Feature.action == 'm') 164 | 165 | regions = [] 166 | if project.regional: 167 | regions = Feature.select( 168 | Feature.region, fn.Count(), 169 | # fn.Sum(Case(None, [(Feature.validates_count >= 1, 1)], 0))).where( 170 | fn.Sum(fn.Min(Feature.validates_count, 1))).where( 171 | Feature.project == project).group_by( 172 | Feature.region).order_by(Feature.region).tuples() 173 | if len(regions) == 1: 174 | regions = [] 175 | else: 176 | regions = [(None, cnt.count(), val1.count())] + list(regions) 177 | 178 | user = get_user() 179 | if user: 180 | has_skipped = Task.select().join(Feature).where( 181 | Task.user == user, Task.skipped == True, Feature.project == project).count() > 0 182 | else: 183 | has_skipped = False 184 | return render_template('project.html', project=project, admin=is_admin(user, project), 185 | count=cnt.count(), desc=desc, val1=val1.count(), val2=val2.count(), 186 | corrected=corrected.count(), skipped=skipped.count(), 187 | has_skipped=has_skipped, region=region, regions=regions) 188 | 189 | 190 | @app.route('/browse/') 191 | @app.route('/browse//') 192 | def browse(name, ref=None, region=None): 193 | project = Project.get(Project.name == name) 194 | region = request.args.get('region') 195 | return render_template('browse.html', project=project, ref=ref, region=region, 196 | mapillary_id=config.MAPILLARY_CLIENT_ID) 197 | 198 | 199 | @app.route('/map/') 200 | @app.route('/map//') 201 | def show_map(name, ref=None, region=None): 202 | project = Project.get(Project.name == name) 203 | region = request.args.get('region') 204 | return render_template('map.html', project=project, ref=ref, region=region) 205 | 206 | 207 | @app.route('/run/') 208 | @app.route('/run//') 209 | def tasks(name, ref=None, region=None): 210 | if not get_user(): 211 | return redirect(url_for('login', next=request.path)) 212 | project = Project.get(Project.name == name) 213 | region = request.args.get('region') 214 | if not project.can_validate: 215 | if ref: 216 | return redirect(url_for('browse', name=name, ref=ref)) 217 | else: 218 | flash('Project validation is disabled') 219 | return redirect(url_for('project', name=name)) 220 | return render_template('task.html', project=project, ref=ref, region=region, 221 | mapillary_id=config.MAPILLARY_CLIENT_ID) 222 | 223 | 224 | # Lifted from http://flask.pocoo.org/snippets/44/ 225 | class Pagination(object): 226 | def __init__(self, page, per_page, total_count): 227 | self.page = page 228 | self.per_page = per_page 229 | self.total_count = total_count 230 | 231 | @property 232 | def pages(self): 233 | return int(math.ceil(self.total_count / float(self.per_page))) 234 | 235 | @property 236 | def has_prev(self): 237 | return self.page > 1 238 | 239 | @property 240 | def has_next(self): 241 | return self.page < self.pages 242 | 243 | def iter_pages(self, left_edge=2, left_current=2, right_current=5, right_edge=2): 244 | last = 0 245 | for num in xrange(1, self.pages + 1): 246 | if (num <= left_edge or num > self.pages - right_edge or 247 | (num > self.page - left_current - 1 and num < self.page + right_current)): 248 | if last + 1 != num: 249 | yield None 250 | yield num 251 | last = num 252 | 253 | 254 | def url_for_other_page(page): 255 | args = request.view_args.copy() 256 | args['page'] = page 257 | show_validated = request.args.get('all') == '1' 258 | if show_validated: 259 | args['all'] = '1' 260 | return url_for(request.endpoint, **args) 261 | app.jinja_env.globals['url_for_other_page'] = url_for_other_page 262 | 263 | 264 | @app.route('/table/', defaults={'page': 1}) 265 | @app.route('/table//') 266 | def table(name, page): 267 | PER_PAGE = 200 268 | project = Project.get(Project.name == name) 269 | region = request.args.get('region') 270 | query = Feature.select().where(Feature.project == project).order_by( 271 | Feature.id).paginate(page, PER_PAGE) 272 | show_validated = request.args.get('all') == '1' 273 | if not show_validated: 274 | query = query.where(Feature.validates_count < 2) 275 | if region: 276 | query = query.where(Feature.region == region) 277 | pagination = Pagination(page, PER_PAGE, query.count(True)) 278 | columns = set() 279 | features = [] 280 | for feature in query: 281 | data = json.loads(feature.feature) 282 | audit = json.loads(feature.audit or 'null') 283 | if audit and len(audit.get('move', '')) == 2: 284 | coord = audit['move'] 285 | else: 286 | coord = data['geometry']['coordinates'] 287 | f = {'ref': feature.ref, 'lon': coord[0], 'lat': coord[1], 288 | 'action': data['properties']['action']} 289 | tags = {} 290 | for p, v in data['properties'].items(): 291 | if not p.startswith('tags') and not p.startswith('ref_unused_tags'): 292 | continue 293 | k = p[p.find('.')+1:] 294 | if k.startswith('ref'): 295 | continue 296 | tag = {} 297 | if data['properties']['action'] in ('create', 'delete') and p.startswith('tags.'): 298 | columns.add(k) 299 | tag['before'] = '' 300 | tag['after'] = v 301 | tag['accepted'] = not audit or k not in audit.get('keep', []) 302 | tag['action'] = data['properties']['action'] 303 | else: 304 | if p.startswith('tags.'): 305 | continue 306 | if p.startswith('tags_') or p.startswith('ref_unused_tags'): 307 | columns.add(k) 308 | tag['accepted'] = p.startswith('tags_') or ( 309 | audit and k in audit.get('override', [])) 310 | if p.startswith('tags_new'): 311 | tag['before'] = '' 312 | tag['after'] = v 313 | tag['action'] = 'created' 314 | elif p.startswith('tags_del'): 315 | tag['before'] = '' # swapping to print deleted value 316 | tag['after'] = v 317 | tag['action'] = 'deleted' 318 | elif p.startswith('tags_cha'): 319 | i = v.find(' -> ') 320 | tag['before'] = v[:i] 321 | tag['after'] = v[i+4:] 322 | tag['action'] = 'changed' 323 | elif p.startswith('ref_unused'): 324 | tag['before'] = data['properties'].get('tags.'+k, '') 325 | tag['after'] = v 326 | tag['action'] = 'changed' 327 | tags[k] = tag 328 | f['tags'] = tags 329 | features.append(f) 330 | 331 | return render_template('table.html', project=project, pagination=pagination, 332 | columns=sorted(columns), rows=features, 333 | show_validated=show_validated) 334 | 335 | 336 | @app.route('/newproject') 337 | @app.route('/editproject/') 338 | def add_project(pid=None): 339 | user = get_user() 340 | if not is_admin(user): 341 | return redirect(url_for('front')) 342 | if pid: 343 | project = Project.get(Project.id == pid) 344 | else: 345 | project = Project() 346 | return render_template('newproject.html', project=project) 347 | 348 | 349 | @app.route('/newproject/upload', methods=['POST']) 350 | def upload_project(): 351 | def add_flash(pid, msg): 352 | flash(msg) 353 | return redirect(url_for('add_project', pid=pid)) 354 | 355 | user = get_user() 356 | if not is_admin(user): 357 | return redirect(url_for('front')) 358 | pid = request.form['pid'] 359 | if pid: 360 | pid = int(pid) 361 | project = Project.get(Project.id == pid) 362 | if not is_admin(user, project): 363 | return redirect(url_for('front')) 364 | update_audit(project) 365 | else: 366 | pid = None 367 | project = Project() 368 | project.feature_count = 0 369 | project.bbox = '' 370 | project.owner = user 371 | project.regional = True 372 | project.name = request.form['name'].strip() 373 | if not project.name: 374 | return add_flash(pid, 'Empty name - bad') 375 | project.title = request.form['title'].strip() 376 | if not project.title: 377 | return add_flash(pid, 'Empty title - bad') 378 | project.url = request.form['url'].strip() 379 | if not project.url: 380 | project.url = None 381 | project.description = request.form['description'].strip() 382 | project.can_validate = request.form.get('validate') is not None 383 | project.validate_modified = request.form.get('validate_modified') is not None 384 | project.hidden = request.form.get('is_hidden') is not None 385 | project.regional = request.form.get('regional') is not None 386 | project.prop_sv = request.form.get('prop_sv') is not None 387 | 388 | if 'json' not in request.files or request.files['json'].filename == '': 389 | if not pid: 390 | return add_flash(pid, 'Would not create a project without features') 391 | features = [] 392 | else: 393 | try: 394 | features = json.load(codecs.getreader('utf-8')(request.files['json'])) 395 | except ValueError as e: 396 | return add_flash(pid, 'Error in the uploaded features file: {}'.format(e)) 397 | if 'features' not in features or not features['features']: 398 | return add_flash(pid, 'No features found in the JSON file') 399 | features = features['features'] 400 | 401 | audit = None 402 | if 'audit' in request.files and request.files['audit'].filename: 403 | try: 404 | audit = json.load(codecs.getreader('utf-8')(request.files['audit'])) 405 | except ValueError as e: 406 | return add_flash(pid, 'Error in the uploaded audit file: {}'.format(e)) 407 | if not audit: 408 | return add_flash(pid, 'No features found in the audit JSON file') 409 | 410 | proj_audit = json.loads(project.audit or '{}') 411 | if audit: 412 | proj_audit.update(audit) 413 | project.audit = json.dumps(proj_audit, ensure_ascii=False) 414 | if features or audit or not project.updated: 415 | project.updated = datetime.datetime.utcnow().date() 416 | project.save() 417 | 418 | if features or audit: 419 | with database.atomic(): 420 | update_features(project, features, proj_audit) 421 | 422 | if project.feature_count == 0 and not pid: 423 | project.delete_instance() 424 | return add_flash(pid, 'Zero features in the JSON file') 425 | 426 | return redirect(url_for('project', name=project.name)) 427 | 428 | 429 | @app.route('/clear_skipped/') 430 | def clear_skipped(pid): 431 | project = Project.get(Project.id == pid) 432 | user = get_user() 433 | if user: 434 | features = Feature.select().where(Feature.project == project) 435 | query = Task.delete().where( 436 | Task.user == user, Task.skipped == True, 437 | Task.feature.in_(features)) 438 | query.execute() 439 | return redirect(url_for('project', name=project.name)) 440 | 441 | 442 | @app.route('/delete/') 443 | def delete_project(pid): 444 | project = Project.get(Project.id == pid) 445 | if not is_admin(get_user(), project): 446 | return redirect(url_for('front')) 447 | project.delete_instance(recursive=True) 448 | return redirect(url_for('front')) 449 | 450 | 451 | @app.route('/export_audit/') 452 | def export_audit(pid): 453 | project = Project.get(Project.id == pid) 454 | if not is_admin(get_user(), project): 455 | return redirect(url_for('front')) 456 | audit = update_audit(project) 457 | try: 458 | project.save() 459 | except OperationalError: 460 | pass 461 | return app.response_class( 462 | audit or '{}', mimetype='application/json', 463 | headers={'Content-Disposition': 'attachment;filename=audit_{}.json'.format(project.name)}) 464 | 465 | 466 | @app.route('/external_audit/') 467 | def external_audit(pid): 468 | project = Project.get(Project.id == pid) 469 | if not is_admin(get_user(), project): 470 | return redirect(url_for('front')) 471 | query = Feature.select().where(Feature.project == project, Feature.audit.is_null(False)) 472 | result = {} 473 | for feat in query: 474 | audit = json.loads(feat.audit or {}) 475 | props = json.loads(feat.feature)['properties'] 476 | eaudit = {} 477 | if 'move' in audit: 478 | if audit['move'] == 'osm': 479 | if 'were_coords' in props['were_coords']: 480 | eaudit['move'] = props['were_coords'] 481 | elif isinstance(audit['move'], list) and len(audit['move']) == 2: 482 | eaudit['move'] = audit['move'] 483 | if 'keep' in audit: 484 | keep = {} 485 | for k in audit['keep']: 486 | orig = None 487 | if 'tags_deleted.'+k in props: 488 | orig = props['tags_deleted.'+k] 489 | elif 'tags_changed.'+k in props: 490 | orig = props['tags_changed.'+k] 491 | orig = orig[:orig.find(' -> ')] 492 | if orig: 493 | keep[k] = orig 494 | if keep: 495 | eaudit['keep'] = keep 496 | if audit.get('skip'): 497 | if audit.get('comment', '').lower() != 'duplicate': 498 | eaudit['skip'] = audit.get('comment', '') 499 | if eaudit: 500 | result[feat.ref] = eaudit 501 | return app.response_class( 502 | json.dumps(result, ensure_ascii=False, indent=1, sort_keys=True), 503 | mimetype='application/json', headers={ 504 | 'Content-Disposition': 'attachment;filename=ext_audit_{}.json'.format(project.name) 505 | }) 506 | 507 | 508 | @app.route('/admin') 509 | def admin(): 510 | user = get_user() 511 | if not user or user.uid not in config.ADMINS: 512 | return redirect(url_for('front')) 513 | admin_uids = User.select(User.uid).where(User.admin == True).tuples() 514 | uids = '\n'.join([str(u[0]) for u in admin_uids]) 515 | return render_template('admin.html', uids=uids) 516 | 517 | 518 | @app.route('/admin_users', methods=['POST']) 519 | def admin_users(): 520 | uids = [int(x.strip()) for x in request.form['uids'].split()] 521 | User.update(admin=False).where(User.uid.not_in(uids)).execute() 522 | User.update(admin=True).where(User.uid.in_(uids)).execute() 523 | return redirect(url_for('admin')) 524 | 525 | 526 | @app.route('/profile', methods=['GET', 'POST']) 527 | def profile(): 528 | user = get_user() 529 | if not user: 530 | return redirect(url_for('login', next=request.path)) 531 | if request.method == 'POST': 532 | user.bboxes = request.form['bboxes'] 533 | user.save() 534 | return render_template('profile.html', user=user) 535 | 536 | 537 | @app.route('/api') 538 | def api(): 539 | return 'API Endpoint' 540 | 541 | 542 | @app.route('/api/features/.js') 543 | def all_features(pid): 544 | project = Project.get(Project.id == pid) 545 | region = request.args.get('region') 546 | if region: 547 | query = Feature.select(Feature.ref, Feature.lat, Feature.lon, Feature.action).where( 548 | Feature.project == project, Feature.region == region).tuples() 549 | features = [] 550 | for ref, lat, lon, action in query: 551 | features.append([ref, [lat/1e7, lon/1e7], action]) 552 | features_js = json.dumps(features, ensure_ascii=False) 553 | else: 554 | if not project.features_js: 555 | update_features_cache(project) 556 | try: 557 | project.save() 558 | except OperationalError: 559 | # Sometimes wait is too long and MySQL disappears 560 | pass 561 | features_js = project.features_js 562 | return app.response_class('features = {}'.format(features_js), 563 | mimetype='application/javascript') 564 | 565 | 566 | class BBoxes(object): 567 | def __init__(self, user): 568 | self.bboxes = [] 569 | if user.bboxes: 570 | for bbox in user.bboxes.split(';'): 571 | self.bboxes.append([float(x.strip()) for x in bbox.split(',')]) 572 | 573 | def update(self, user): 574 | if not self.bboxes: 575 | user.bboxes = None 576 | user.bboxes = ';'.join([','.join(x) for x in self.bboxes]) 577 | 578 | def contains(self, lat, lon): 579 | for bbox in self.bboxes: 580 | if bbox[0] <= lat <= bbox[2] and bbox[1] <= lon <= bbox[3]: 581 | return True 582 | return False 583 | 584 | 585 | @app.route('/api/feature/', methods=['GET', 'POST']) 586 | def api_feature(pid): 587 | user = get_user() 588 | project = Project.get(Project.id == pid) 589 | if user and request.method == 'POST' and project.can_validate: 590 | ref_and_audit = request.get_json() 591 | if ref_and_audit and len(ref_and_audit) == 2: 592 | skipped = ref_and_audit[1] is None 593 | feat = Feature.get(Feature.project == project, Feature.ref == ref_and_audit[0]) 594 | user_did_it = Task.select(Task.id).where( 595 | Task.user == user, Task.feature == feat).count() > 0 596 | Task.create(user=user, feature=feat, skipped=skipped) 597 | if not skipped: 598 | if len(ref_and_audit[1]): 599 | new_audit = json.dumps(ref_and_audit[1], sort_keys=True, ensure_ascii=False) 600 | else: 601 | new_audit = None 602 | if feat.audit != new_audit: 603 | feat.audit = new_audit 604 | feat.validates_count = 1 605 | elif not user_did_it: 606 | feat.validates_count += 1 607 | feat.save() 608 | region = request.args.get('region') 609 | fref = request.args.get('ref') 610 | if fref: 611 | feature = Feature.get(Feature.project == project, Feature.ref == fref) 612 | elif not user or request.args.get('browse') == '1': 613 | query = Feature.select().where(Feature.project == project) 614 | if region: 615 | query = query.where(Feature.region == region) 616 | feature = query.order_by(fn_Random()).get() 617 | else: 618 | try: 619 | # Maybe use a join: https://stackoverflow.com/a/35927141/1297601 620 | task_query = Task.select(Task.id).where(Task.user == user, Task.feature == Feature.id) 621 | query = Feature.select().where( 622 | Feature.project == project, Feature.validates_count < 2).where( 623 | ~fn.EXISTS(task_query)).order_by(Feature.validates_count, fn_Random()) 624 | if project.validate_modified: 625 | query = query.where(Feature.action == 'm') 626 | if region: 627 | query = query.where(Feature.region == region) 628 | if user.bboxes: 629 | bboxes = BBoxes(user) 630 | feature = None 631 | for f in query: 632 | if bboxes.contains(f.lat/1e7, f.lon/1e7): 633 | feature = f 634 | break 635 | elif not feature: 636 | feature = f 637 | if not feature: 638 | raise Feature.DoesNotExist() 639 | else: 640 | feature = query.get() 641 | except Feature.DoesNotExist: 642 | return jsonify(feature={}, ref=None, audit=None) 643 | return jsonify(feature=json.loads(feature.feature), ref=feature.ref, 644 | audit=json.loads(feature.audit or 'null')) 645 | --------------------------------------------------------------------------------