diff --git a/messages/en.json b/messages/en.json index faeccb9..e0674f3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,47 +1,51 @@ { "@metadata": { "authors": [ "Martin Urbanec" ] }, "prague": "Prague", "default-value": "default value", "enter-number-of-item": "Wikidata item number. Objects will be searched around this item.", "enter-coordinates": "Search for objects with coordinates", "enter-name-of-article": "Search for objects around coordinates of this article", "enter-radius": "Set a radius for searching (km)", "select-displayed": "Choose objects that should be displayed on a map", "search": "Search", "photographed": "Photographed", "unphotographed": "Unphotographed", "report-problem": "Report problem", "source-code": "Source code", "your-email": "Your email", "problem-summary": "Summary of the problem", "problem-body": "Tell us more details about your problem", "submit": "Submit", "stats-before": "This tool has generated", "stats-after": "maps already", "no-coordinates": "We were not able to find coordinates from info you submitted.", "change-language": "Change language", "admin-link": "Admin", "admin-interface": "Admin interface", "admin-description": "You can edit queries Wikinity uses here.", "admin-variables": "List of variables", "and": "and", "admin-description-latlon": "Contains coordinates, only used when searching via coordinates", "admin-description-item": "Contains item ID (Q is included), only used when searching via article name or item number", "admin-description-radius": "Contains radius, always used", "admin-success": "Your request to change queries was successfully completed.", "admin-layers": "Layers", "admin-coordinate": "Start query with this when searching by coordinate", "admin-item": "Start query with this when searching by item", "admin-unphotographed": "Add this to WHERE when searching for unphotographed objects", "admin-photographed": "Add this to WHERE when searching for photographed objects", "admin-all": "Add this to WHERE when search for all objects", "admin-end": "End query with this", "welcome": "Welcome", "login": "Login", "logout": "Logout", - "permission-denied": "Permission denied. If you believe this happened in error, contact an administrator." + "permission-denied": "Permission denied. If you believe this happened in error, contact an administrator.", + "new": "new", + "admin-layer-name": "Name", + "admin-layer-color": "Color", + "admin-layer-definition": "Definition" } diff --git a/src/app.py b/src/app.py index 3be61f6..b443290 100644 --- a/src/app.py +++ b/src/app.py @@ -1,274 +1,326 @@ # -*- coding: utf-8 -*- # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) # any later version. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for # more details. # # You should have received a copy of the GNU General Public License along # with this program. If not, see . import flask import os import yaml import simplejson as json from flask import redirect, request, jsonify, make_response, render_template, session, url_for from flask import Flask import requests import urllib.parse import toolforge import pymysql import mwoauth from flask_jsonlocale import Locales from SPARQLWrapper import SPARQLWrapper, JSON app = Flask(__name__) # Load configuration from YAML file __dir__ = os.path.dirname(__file__) app.config.update( yaml.safe_load(open(os.path.join(__dir__, 'config.yaml')))) locales = Locales(app) stats_filename = app.config.get('STATS_COUNTER_FILE', '/tmp/wikinity-stats.txt') QUERY_TYPES = [ - "layers", "coordinate", "item", "photographed", "unphotographed", "all", "end", ] @app.before_request def force_https(): if request.headers.get('X-Forwarded-Proto') == 'http': return redirect( 'https://' + request.headers['Host'] + request.headers['X-Original-URI'], code=301 ) @app.before_request def db_check_language_permissions(): if logged(): conn = connect() with conn.cursor() as cur: cur.execute('SELECT id, is_active, language FROM users WHERE username=%s', getusername()) data = cur.fetchall() if len(data) == 0: with conn.cursor() as cur: cur.execute('INSERT INTO users(username, language) VALUES (%s, %s)', (getusername(), locales.get_locale())) conn.commit() else: if data[0][1] == 1: locales.set_locale(data[0][2]) else: return render_template('permission_denied.html') @app.context_processor def inject_base_variables(): return { "logged": logged(), "username": getusername(), "admin": isadmin() } def connect(): if app.config.get('DB_CONF'): return pymysql.connect( database=app.config.get('DB_NAME'), host=app.config.get('DB_HOST'), read_default_file=app.config.get('DB_CONF') ) else: return pymysql.connect( database=app.config.get('DB_NAME'), host=app.config.get('DB_HOST'), user=app.config.get('DB_USER'), password=app.config.get('DB_PASS') ) def logged(): return flask.session.get('username') != None def getusername(): return flask.session.get('username') def isadmin(): if logged(): conn = connect() with conn.cursor() as cur: cur.execute('SELECT username FROM users WHERE is_active=1 AND is_admin=1 AND username=%s', getusername()) return len(cur.fetchall()) == 1 else: return False @app.route('/') def index(): return render_template('index.html') @app.route('/change_language', methods=['GET', 'POST']) def change_language(): if request.method == 'GET': return render_template('change_language.html', locales=locales.get_locales(), permanent_locale=locales.get_permanent_locale()) else: if logged(): conn = connect() with conn.cursor() as cur: cur.execute('UPDATE users SET language=%s WHERE username=%s', (request.form.get('locale', 'en'), getusername())) conn.commit() locales.set_locale(request.form.get('locale')) return redirect(url_for('index')) @app.route('/stats') def stats(): if os.path.isfile(stats_filename): return open(stats_filename).read() return '0' @app.route('/map') def map(): try: stats_num = str(int(open(stats_filename).read())+1) open(stats_filename, 'w').write(stats_num) except: open(stats_filename, 'w').write('1') typ = request.args.get('type', 'item') subtype = request.args.get('subtype', 'unphotographed') radius = int(request.args.get('radius') or 5) if typ == "coordinate": lat = request.args.get('lat') or '50.0385383' lon = request.args.get('lon') or '15.7802056' else: if typ == "article": typ = "item" article = request.args.get('article') or 'Praha' project = request.args.get('project') or 'cswiki' r = requests.get('https://www.wikidata.org/w/api.php', params={ "action": "wbgetentities", "format": "json", "sites": project, "titles": article }) item = list(r.json()['entities'].keys())[0] if item == '-1': item = 'Q1085' else: item = request.args.get('item') or 'Q1085' - query = "\n".join((get_query(typ), get_query("layers"), get_query(subtype), get_query("end"))) + query = "\n".join((get_query(typ), get_layers_query(), get_query(subtype), get_query("end"))) if typ == "coor": query = query.replace('@@LAT@@', lat).replace('@@LON@@', lon).replace('@@RADIUS@@', str(radius)) else: query = query.replace('@@ITEM@@', item).replace('@@RADIUS@@', str(radius)) if request.args.get('onlyquery'): return query sparql = SPARQLWrapper("https://query.wikidata.org/sparql") sparql.setQuery(query) sparql.setReturnFormat(JSON) res = sparql.query().convert() return jsonify(res) def get_query(typ): conn = connect() with conn.cursor() as cur: cur.execute('SELECT query FROM query WHERE type=%s ORDER BY timestamp DESC LIMIT 1', typ) data = cur.fetchall() if len(data) == 1: return data[0][0] else: return '' def get_queries(): conn = connect() queries = {} queries = {} for typ in QUERY_TYPES: queries[typ] = get_query(typ) return queries +def get_layers(): + conn = connect() + with conn.cursor() as cur: + cur.execute('SELECT * FROM layers') + return cur.fetchall() + +def get_layer(id): + conn = connect() + with conn.cursor() as cur: + cur.execute('SELECT * FROM layers WHERE id=%s', id) + return cur.fetchall()[0] + +def get_layers_query(): + layers = get_layers() + res = "" + for layer in layers: + res += """ + OPTIONAL { + %s + BIND("%s" AS ?layer) + BIND("%s" as ?rgb) + } + """ % (layer[2], layer[3], layer[1]) + return res + @app.route('/admin', methods=['GET', 'POST']) def admin(): if logged(): if not isadmin(): return render_template('permission_denied.html') if request.method == 'GET': return render_template('admin.html', queries=get_queries()) else: conn = connect() queries = get_queries() for typ in request.form: if typ.startswith('query-') and queries[typ.replace('query-', '')] != request.form.get(typ): with conn.cursor() as cur: cur.execute('INSERT INTO query(query, type, username) VALUES(%s, %s, %s)', (request.form.get(typ), typ.replace('query-', ''), getusername())) conn.commit() return render_template('admin.html', queries=get_queries(), success=True) else: return redirect(url_for("login")) +@app.route('/admin/layers') +def admin_layers(): + return render_template('admin_layers.html', layers=get_layers()) + +@app.route('/admin/layer/new', methods=['GET', 'POST']) +def admin_layer_new(): + if request.method == 'GET': + return render_template('admin_layer.html') + else: + conn = connect() + with conn.cursor() as cur: + cur.execute('INSERT INTO layers(color, definition, name) VALUES(%s, %s, %s)', (request.form['color'], request.form['definition'], request.form['name'])) + conn.commit() + with conn.cursor() as cur: + cur.execute('SELECT id FROM layers ORDER BY id DESC') + return redirect(url_for('admin_layer', id=cur.fetchall()[0][0])) + +@app.route('/admin/layer/', methods=['GET', 'POST']) +def admin_layer(id): + if request.method == 'GET': + return render_template('admin_layer.html', layer=get_layer(id)) + else: + conn = connect() + with conn.cursor() as cur: + cur.execute('UPDATE layers SET color=%s, definition=%s, name=%s WHERE id=%s', (request.form['color'], request.form['definition'], request.form['name'], id)) + conn.commit() + return render_template('admin_layer.html', layer=get_layer(id), success=True) + @app.route('/login') def login(): """Initiate an OAuth login. Call the MediaWiki server to get request secrets and then redirect the user to the MediaWiki server to sign the request. """ consumer_token = mwoauth.ConsumerToken( app.config['CONSUMER_KEY'], app.config['CONSUMER_SECRET']) try: redirect, request_token = mwoauth.initiate( app.config['OAUTH_MWURI'], consumer_token) except Exception: app.logger.exception('mwoauth.initiate failed') return flask.redirect(flask.url_for('index')) else: flask.session['request_token'] = dict(zip( request_token._fields, request_token)) return flask.redirect(redirect) @app.route('/oauth-callback') def oauth_callback(): """OAuth handshake callback.""" if 'request_token' not in flask.session: flask.flash(u'OAuth callback failed. Are cookies disabled?') return flask.redirect(flask.url_for('index')) consumer_token = mwoauth.ConsumerToken(app.config['CONSUMER_KEY'], app.config['CONSUMER_SECRET']) try: access_token = mwoauth.complete( app.config['OAUTH_MWURI'], consumer_token, mwoauth.RequestToken(**flask.session['request_token']), flask.request.query_string) identity = mwoauth.identify(app.config['OAUTH_MWURI'], consumer_token, access_token) except Exception: app.logger.exception('OAuth authentication failed') else: flask.session['request_token_secret'] = dict(zip(access_token._fields, access_token))['secret'] flask.session['request_token_key'] = dict(zip(access_token._fields, access_token))['key'] flask.session['username'] = identity['username'] return flask.redirect(flask.url_for('index')) @app.route('/logout') def logout(): """Log the user out by clearing their session.""" flask.session.clear() return flask.redirect(flask.url_for('index')) if __name__ == "__main__": app.run(debug=True, threaded=True) diff --git a/src/templates/admin_layer.html b/src/templates/admin_layer.html new file mode 100644 index 0000000..5732a32 --- /dev/null +++ b/src/templates/admin_layer.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} +{% block content %} +
+

Edit layer - {% if layer %} {{layer.3}} {% else %}{{locale['new']}}{% endif %}

+
+ + + + + + + +
+
+{% endblock %} \ No newline at end of file diff --git a/src/templates/admin_layers.html b/src/templates/admin_layers.html new file mode 100644 index 0000000..c80f013 --- /dev/null +++ b/src/templates/admin_layers.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} +{% block content %} +
+

Layers

+
    + {% for layer in layers %} +
  • {{layer.3}}
  • + {% endfor %} +
+ + Add layer +
+{% endblock %} \ No newline at end of file diff --git a/static/map.js b/static/map.js index bdc22c9..017ce9a 100644 --- a/static/map.js +++ b/static/map.js @@ -1,76 +1,82 @@ +var mapsPlaceholder = [] +L.Map.addInitHook(function () { + mapsPlaceholder.push(this); +}); function GetValues() { + if(mapsPlaceholder.length == 1) {mapsPlaceholder[0].remove()} + $('#map').attr("class", ""); $('#map').html("

Processing...

"); var type = $('input[name="optradio"]:checked').val(); var subtype = "unphotographed"; if ($('#nafocene')[0].checked && $('#nenafocene')[0].checked) subtype = "all"; else if ($('#nafocene')[0].checked && !$('#nenafocene')[0].checked) subtype = "unphotographed"; else if (!$('#nafocene')[0].checked && $('#nenafocene')[0].checked) subtype = "photographed"; var payload = { type: type, subtype: subtype, radius: $('#radius').val(), }; if (type == "article") { payload["article"] = $('input[name="wikiSearchPole"]').val(); payload["project"] = $('#project-language').val() + $('#project-project').val(); } else if (type == "item") { payload["item"] = $('input[name="cislo"]').val(); } else if (type == "coordinate") { payload["lat"] = $('input[name="cislo"]').val(); payload["lon"] = $('input[name="lon"]').val(); } $.get('map', payload, function(data) { $('#map').html("").addClass("bigmap"); var style = 'osm-intl'; var server = 'https://maps.wikimedia.org/'; var map = L.map('map'); map.setView([50, 16], 7); L.tileLayer(server + style + '/{z}/{x}/{y}.png', { maxZoom: 18, id: 'wikipedia-map-01', attribution: 'Wikimedia maps beta | Map data © OpenStreetMap contributors' }).addTo(map); var markers = L.markerClusterGroup(); for(var i = 0; i < data.results.bindings.length; i++) { var pointData = data.results.bindings[i]; var coor = pointData.coord.value.replace("Point(", "").replace(")", "").split(" "); if(pointData.rgb) { var markerHtmlStyles = ` background-color: #${pointData.rgb.value}; width: 3rem; height: 3rem; display: block; left: -1.5rem; top: -1.5rem; position: relative; border-radius: 3rem 3rem 0; transform: rotate(45deg); border: 1px solid #FFFFFF`; } else { continue; } var marker = L.marker(new L.LatLng(coor[1], coor[0]), { icon: L.divIcon({ className: "my-custom-pin", iconAnchor: [0, 24], labelAnchor: [-6, 0], popupAnchor: [0, -36], html: `` }), title: pointData.itemLabel.value }); marker.bindPopup(`${pointData.itemLabel.value}`); markers.addLayer(marker); } map.addLayer(markers); $.get("stats", function (data, status) { $('#statnum').text(data) }) }) } \ No newline at end of file