Exclude code about roads that cross borders: it needs 'crossing' and 'points' tables and there are no scripts/instructions how to fill them
This commit is contained in:
parent
580a1ab9ac
commit
4a3942e971
4 changed files with 37 additions and 315 deletions
|
@ -173,99 +173,55 @@ def query_small_in_bbox():
|
|||
else:
|
||||
table = config.TABLE
|
||||
cur = g.conn.cursor()
|
||||
cur.execute('''SELECT name, round(ST_Area(geography(ring))) as area, ST_X(ST_Centroid(ring)), ST_Y(ST_Centroid(ring))
|
||||
cur.execute(f"""
|
||||
SELECT name, round(ST_Area(geography(ring))) as area,
|
||||
ST_X(ST_Centroid(ring)), ST_Y(ST_Centroid(ring))
|
||||
FROM (
|
||||
SELECT name, (ST_Dump(geom)).geom as ring
|
||||
FROM {table}
|
||||
WHERE geom && ST_MakeBox2D(ST_Point(%s, %s), ST_Point(%s, %s))
|
||||
) g
|
||||
WHERE ST_Area(geography(ring)) < %s;'''.format(table=table), (xmin, ymin, xmax, ymax, config.SMALL_KM2 * 1000000))
|
||||
WHERE ST_Area(geography(ring)) < %s
|
||||
""", (xmin, ymin, xmax, ymax, config.SMALL_KM2 * 1000000)
|
||||
)
|
||||
result = []
|
||||
for rec in cur:
|
||||
result.append({ 'name': rec[0], 'area': rec[1], 'lon': float(rec[2]), 'lat': float(rec[3]) })
|
||||
result.append({ 'name': rec[0], 'area': rec[1],
|
||||
'lon': float(rec[2]), 'lat': float(rec[3]) })
|
||||
return jsonify(features=result)
|
||||
|
||||
@app.route('/routing')
|
||||
def query_routing_points():
|
||||
xmin = request.args.get('xmin')
|
||||
xmax = request.args.get('xmax')
|
||||
ymin = request.args.get('ymin')
|
||||
ymax = request.args.get('ymax')
|
||||
cur = g.conn.cursor()
|
||||
try:
|
||||
cur.execute('''SELECT ST_X(geom), ST_Y(geom), type
|
||||
FROM points
|
||||
WHERE geom && ST_MakeBox2D(ST_Point(%s, %s), ST_Point(%s, %s)
|
||||
);''', (xmin, ymin, xmax, ymax))
|
||||
except psycopg2.Error as e:
|
||||
return jsonify(features=[])
|
||||
result = []
|
||||
for rec in cur:
|
||||
result.append({ 'lon': rec[0], 'lat': rec[1], 'type': rec[2] })
|
||||
return jsonify(features=result)
|
||||
|
||||
@app.route('/crossing')
|
||||
def query_crossing():
|
||||
xmin = request.args.get('xmin')
|
||||
xmax = request.args.get('xmax')
|
||||
ymin = request.args.get('ymin')
|
||||
ymax = request.args.get('ymax')
|
||||
region = request.args.get('region', '').encode('utf-8')
|
||||
points = request.args.get('points') == '1'
|
||||
rank = request.args.get('rank') or '4'
|
||||
cur = g.conn.cursor()
|
||||
sql = """SELECT id, ST_AsGeoJSON({line}, 7) as geometry, region, processed FROM {table}
|
||||
WHERE line && ST_MakeBox2D(ST_Point(%s, %s), ST_Point(%s, %s)) and processed = 0 {reg} and rank <= %s;
|
||||
""".format(table=config.CROSSING_TABLE, reg='and region = %s' if region else '', line='line' if not points else 'ST_Centroid(line)')
|
||||
params = [xmin, ymin, xmax, ymax]
|
||||
if region:
|
||||
params.append(region)
|
||||
params.append(rank)
|
||||
result = []
|
||||
try:
|
||||
cur.execute(sql, tuple(params))
|
||||
for rec in cur:
|
||||
props = { 'id': rec[0], 'region': rec[2], 'processed': rec[3] }
|
||||
feature = { 'type': 'Feature', 'geometry': json.loads(rec[1]), 'properties': props }
|
||||
result.append(feature)
|
||||
except psycopg2.Error as e:
|
||||
pass
|
||||
return jsonify(type='FeatureCollection', features=result)
|
||||
|
||||
@app.route('/config')
|
||||
def get_server_configuration():
|
||||
osm = False
|
||||
backup = False
|
||||
old = []
|
||||
crossing = False
|
||||
try:
|
||||
cur = g.conn.cursor()
|
||||
cur.execute('select osm_id, ST_Area(way), admin_level, name from {} limit 2;'.format(config.OSM_TABLE))
|
||||
cur.execute(f"""SELECT osm_id, ST_Area(way), admin_level, name
|
||||
FROM {config.OSM_TABLE} LIMIT 2""")
|
||||
if cur.rowcount == 2:
|
||||
osm = True
|
||||
except psycopg2.Error as e:
|
||||
pass
|
||||
try:
|
||||
cur.execute('select backup, id, name, parent_id, ST_Area(geom), modified, disabled, count_k, cmnt from {} limit 2;'.format(config.BACKUP))
|
||||
cur.execute(f"""SELECT backup, id, name, parent_id, ST_Area(geom),
|
||||
modified, disabled, count_k, cmnt
|
||||
FROM {config.BACKUP} LIMIT 2""")
|
||||
backup = True
|
||||
except psycopg2.Error as e:
|
||||
pass
|
||||
for t, tname in config.OTHER_TABLES.items():
|
||||
try:
|
||||
cur.execute('select name, ST_Area(geom), modified, disabled, count_k, cmnt from {} limit 2;'.format(tname))
|
||||
cur.execute(f"""SELECT name, ST_Area(geom), modified, disabled,
|
||||
count_k, cmnt
|
||||
FROM {tname} LIMIT 2""")
|
||||
if cur.rowcount == 2:
|
||||
old.append(t)
|
||||
except psycopg2.Error as e:
|
||||
pass
|
||||
try:
|
||||
cur = g.conn.cursor()
|
||||
cur.execute('select id, ST_Length(line), region, processed from {} limit 2;'.format(config.CROSSING_TABLE))
|
||||
if cur.rowcount == 2:
|
||||
crossing = True
|
||||
except psycopg2.Error as e:
|
||||
pass
|
||||
return jsonify(osm=osm, tables=old, readonly=config.READONLY,
|
||||
backup=backup, crossing=crossing,
|
||||
return jsonify(osm=osm, tables=old,
|
||||
readonly=config.READONLY,
|
||||
backup=backup,
|
||||
mwm_size_thr=config.MWM_SIZE_THRESHOLD)
|
||||
|
||||
@app.route('/search')
|
||||
|
@ -431,7 +387,16 @@ def find_osm_borders():
|
|||
lat = request.args.get('lat')
|
||||
lon = request.args.get('lon')
|
||||
cur = g.conn.cursor()
|
||||
cur.execute("select osm_id, name, admin_level, (case when ST_Area(geography(way)) = 'NaN' then 0 else ST_Area(geography(way))/1000000 end) as area_km from {table} where ST_Contains(way, ST_SetSRID(ST_Point(%s, %s), 4326)) order by admin_level desc, name asc;".format(table=config.OSM_TABLE), (lon, lat))
|
||||
cur.execute(f"""
|
||||
SELECT osm_id, name, admin_level,
|
||||
(CASE
|
||||
WHEN ST_Area(geography(way)) = 'NaN' THEN 0
|
||||
ELSE ST_Area(geography(way))/1000000
|
||||
END) AS area_km
|
||||
FROM {config.OSM_TABLE}
|
||||
WHERE ST_Contains(way, ST_SetSRID(ST_Point(%s, %s), 4326))
|
||||
ORDER BY admin_level DESC, name ASC
|
||||
""", (lon, lat))
|
||||
result = []
|
||||
for rec in cur:
|
||||
b = { 'id': rec[0], 'name': rec[1], 'admin_level': rec[2], 'area': rec[3] }
|
||||
|
@ -483,7 +448,7 @@ def delete_border():
|
|||
abort(405)
|
||||
region_id = int(request.args.get('id'))
|
||||
cur = g.conn.cursor()
|
||||
cur.execute('delete from {} where id = %s;'.format(config.TABLE), (region_id,))
|
||||
cur.execute(f"DELETE FROM {config.TABLE} WHERE id = %s", (region_id,))
|
||||
g.conn.commit()
|
||||
return jsonify(status='ok')
|
||||
|
||||
|
@ -883,56 +848,6 @@ def draw_hull():
|
|||
g.conn.commit()
|
||||
return jsonify(status='ok')
|
||||
|
||||
@app.route('/fixcrossing')
|
||||
def fix_crossing():
|
||||
if config.READONLY:
|
||||
abort(405)
|
||||
preview = request.args.get('preview') == '1'
|
||||
region = request.args.get('region').encode('utf-8')
|
||||
if region is None:
|
||||
return jsonify(status='Please specify a region')
|
||||
ids = request.args.get('ids')
|
||||
if ids is None or len(ids) == 0:
|
||||
return jsonify(status='Please specify a list of line ids')
|
||||
ids = tuple(ids.split(','))
|
||||
cur = g.conn.cursor()
|
||||
if preview:
|
||||
cur.execute("""
|
||||
WITH lines as (SELECT ST_Buffer(ST_Collect(line), 0.002, 1) as g FROM {cross} WHERE id IN %s)
|
||||
SELECT ST_AsGeoJSON(ST_Collect(ST_MakePolygon(er.ring))) FROM
|
||||
(
|
||||
SELECT ST_ExteriorRing((ST_Dump(ST_Union(ST_Buffer(geom, 0.0), lines.g))).geom) as ring FROM {table}, lines WHERE name = %s
|
||||
) as er
|
||||
""".format(table=config.TABLE, cross=config.CROSSING_TABLE), (ids, region))
|
||||
res = cur.fetchone()
|
||||
if not res:
|
||||
return jsonify(status='Failed to extend geometry')
|
||||
f = { "type": "Feature", "properties": {}, "geometry": json.loads(res[0]) }
|
||||
#return jsonify(type="FeatureCollection", features=[f])
|
||||
return jsonify(type="Feature", properties={}, geometry=json.loads(res[0]))
|
||||
else:
|
||||
cur.execute("""
|
||||
WITH lines as (SELECT ST_Buffer(ST_Collect(line), 0.002, 1) as g FROM {cross} WHERE id IN %s)
|
||||
UPDATE {table} SET geom = res.g FROM
|
||||
(
|
||||
SELECT ST_Collect(ST_MakePolygon(er.ring)) as g FROM
|
||||
(
|
||||
SELECT ST_ExteriorRing((ST_Dump(ST_Union(ST_Buffer(geom, 0.0), lines.g))).geom) as ring FROM {table}, lines WHERE name = %s
|
||||
) as er
|
||||
) as res
|
||||
WHERE name = %s
|
||||
""".format(table=config.TABLE, cross=config.CROSSING_TABLE), (ids, region, region))
|
||||
cur.execute("""
|
||||
UPDATE {table} b SET geom = ST_Difference(b.geom, o.geom)
|
||||
FROM {table} o
|
||||
WHERE ST_Overlaps(b.geom, o.geom)
|
||||
AND o.name = %s
|
||||
""".format(table=config.TABLE), (region,))
|
||||
cur.execute("UPDATE {cross} SET processed = 1 WHERE id IN %s".format(cross=config.CROSSING_TABLE), (ids,))
|
||||
g.conn.commit()
|
||||
return jsonify(status='ok')
|
||||
|
||||
|
||||
@app.route('/backup')
|
||||
def backup_do():
|
||||
if config.READONLY:
|
||||
|
|
|
@ -18,8 +18,6 @@ OTHER_TABLES = {
|
|||
}
|
||||
# backup table
|
||||
BACKUP = 'borders_backup'
|
||||
## table with crossing lines
|
||||
CROSSING_TABLE = 'crossing'
|
||||
# area of an island for it to be considered small
|
||||
SMALL_KM2 = 10
|
||||
# force multipolygons in JOSM output
|
||||
|
|
|
@ -14,25 +14,15 @@ var size_good, size_bad;
|
|||
var maxRank = 1;
|
||||
var tooSmallLayer = null;
|
||||
var oldBordersLayer = null;
|
||||
var routingGroup = null;
|
||||
var crossingLayer = null;
|
||||
|
||||
function init() {
|
||||
map = L.map('map', { editable: true }).setView([30, 0], 3);
|
||||
var hash = new L.Hash(map);
|
||||
L.tileLayer('https://tile.openstreetmap.de/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap' }).addTo(map);
|
||||
//L.tileLayer('https://b.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap' }).addTo(map);
|
||||
//L.tileLayer('http://tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap' }).addTo(map);
|
||||
// L.tileLayer('http://korona.geog.uni-heidelberg.de/tiles/adminb/x={x}&y={y}&z={z}',
|
||||
// { attribution: '© GIScience Heidelberg' }).addTo(map);
|
||||
// L.tileLayer('https://tile.cyclestreets.net/boundaries/{z}/{x}/{y}.png',
|
||||
// { attribution: '© CycleStreets.net' }).addTo(map);
|
||||
//L.tileLayer('http://tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap' }).addTo(map);
|
||||
bordersLayer = L.layerGroup();
|
||||
map.addLayer(bordersLayer);
|
||||
routingGroup = L.layerGroup();
|
||||
map.addLayer(routingGroup);
|
||||
crossingLayer = L.layerGroup();
|
||||
map.addLayer(crossingLayer);
|
||||
|
||||
map.on('moveend', function() {
|
||||
if( map.getZoom() >= 5 )
|
||||
|
@ -85,8 +75,6 @@ function getServerConfiguration() {
|
|||
$('#old_action').css('display', 'block');
|
||||
$('#josm_old').css('display', 'inline');
|
||||
}
|
||||
if( res.crossing )
|
||||
$('#cross_actions').css('display', 'block');
|
||||
if( res.backup ) {
|
||||
$('#backups').show();
|
||||
}
|
||||
|
@ -128,34 +116,6 @@ function updateBorders() {
|
|||
simplified: simplified
|
||||
});
|
||||
|
||||
/*$.ajax(getServer('routing'), {
|
||||
data: {
|
||||
'xmin': b.getWest(),
|
||||
'xmax': b.getEast(),
|
||||
'ymin': b.getSouth(),
|
||||
'ymax': b.getNorth()
|
||||
},
|
||||
success: processRouting,
|
||||
dataType: 'json'
|
||||
});
|
||||
|
||||
if (map.getZoom() >= 4) {
|
||||
$.ajax(getServer('crossing'), {
|
||||
data: {
|
||||
'xmin': b.getWest(),
|
||||
'xmax': b.getEast(),
|
||||
'ymin': b.getSouth(),
|
||||
'ymax': b.getNorth(),
|
||||
'points': (map.getZoom() < 10 ? 1 : 0),
|
||||
'rank': maxRank
|
||||
},
|
||||
success: processCrossing,
|
||||
dataType: 'json'
|
||||
});
|
||||
} else {
|
||||
crossingLayer.clearLayers();
|
||||
}
|
||||
|
||||
if( oldBordersLayer != null && config.OLD_BORDERS_NAME ) {
|
||||
oldBordersLayer.clearLayers();
|
||||
$.ajax(getServer('bbox'), {
|
||||
|
@ -167,10 +127,10 @@ function updateBorders() {
|
|||
'ymin': b.getSouth(),
|
||||
'ymax': b.getNorth()
|
||||
},
|
||||
success: processOldBorders,
|
||||
success: makeAnswerHandler(processOldBorders),
|
||||
dataType: 'json'
|
||||
});
|
||||
} */
|
||||
}
|
||||
}
|
||||
|
||||
function makeAnswerHandler(on_ok_func) {
|
||||
|
@ -182,18 +142,6 @@ function makeAnswerHandler(on_ok_func) {
|
|||
};
|
||||
}
|
||||
|
||||
routingTypes = {1: "Border and feature are intersecting several times.",
|
||||
2: "Unknown outgoing feature."};
|
||||
|
||||
function processRouting(data) {
|
||||
routingGroup.clearLayers();
|
||||
for( var f = 0; f < data.features.length; f++ ) {
|
||||
marker = L.marker([data.features[f]["lat"], data.features[f]["lon"]]);
|
||||
marker.bindPopup(routingTypes[data.features[f]["type"]], {showOnMouseOver: true});
|
||||
routingGroup.addLayer(marker);
|
||||
}
|
||||
}
|
||||
|
||||
function processBorders(data) {
|
||||
data = data.geojson;
|
||||
for( var id in borders ) {
|
||||
|
@ -240,8 +188,8 @@ function processBorders(data) {
|
|||
}
|
||||
|
||||
function processOldBorders(data) {
|
||||
var layer = L.geoJson(data, {
|
||||
style: { fill: false, color: 'purple', weight: 3, clickable: false }
|
||||
var layer = L.geoJson(data.geojosn, {
|
||||
style: { fill: false, color: 'purple', weight: 5, clickable: false }
|
||||
});
|
||||
oldBordersLayer.addLayer(layer);
|
||||
}
|
||||
|
@ -1064,9 +1012,6 @@ function bDivideCancel() {
|
|||
$('#actions').show();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
function bLargest() {
|
||||
if( !selectedId || !(selectedId in borders) )
|
||||
return;
|
||||
|
@ -1172,131 +1117,6 @@ function getPolyDownloadLink(use_bbox) {
|
|||
return downloadLink;
|
||||
}
|
||||
|
||||
var crossSelected = null, fcPreview = null;
|
||||
var selectedCrossings = {};
|
||||
|
||||
function crossingUpdateColor(layer) {
|
||||
if( 'setStyle' in layer )
|
||||
layer.setStyle({ color: selectedCrossings[layer.crossId] ? 'red' : 'blue' });
|
||||
}
|
||||
|
||||
function crossingClicked(e) {
|
||||
if( !crossSelected )
|
||||
return;
|
||||
var layer = e.target;
|
||||
if( 'crossId' in layer ) {
|
||||
var id = layer.crossId;
|
||||
if( selectedCrossings[id] )
|
||||
delete selectedCrossings[id];
|
||||
else
|
||||
selectedCrossings[id] = true;
|
||||
crossingUpdateColor(layer);
|
||||
}
|
||||
}
|
||||
|
||||
function setBordersSelectable(selectable) {
|
||||
crossingLayer.eachLayer(function(l) {
|
||||
l.bringToFront();
|
||||
});
|
||||
}
|
||||
|
||||
function processCrossing(data) {
|
||||
crossingLayer.clearLayers();
|
||||
for( var f = 0; f < data.features.length; f++ ) {
|
||||
var layer = L.GeoJSON.geometryToLayer(data.features[f].geometry),
|
||||
props = data.features[f].properties;
|
||||
layer.crossId = '' + props.id;
|
||||
layer.crossRegion = props.region;
|
||||
crossingUpdateColor(layer);
|
||||
layer.on('click', crossingClicked);
|
||||
crossingLayer.addLayer(layer);
|
||||
}
|
||||
}
|
||||
|
||||
function selectCrossingByRegion(region) {
|
||||
if( region ) {
|
||||
crossingLayer.eachLayer(function(l) {
|
||||
if( l.crossId && l.crossRegion == region ) {
|
||||
selectedCrossings[l.crossId] = true;
|
||||
crossingUpdateColor(l);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
crossingLayer.eachLayer(function(l) {
|
||||
if( l.crossId ) {
|
||||
delete selectedCrossings[l.crossId];
|
||||
crossingUpdateColor(l);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bFixCross() {
|
||||
if( !selectedId || !(selectedId in borders) )
|
||||
return;
|
||||
setBordersSelectable(false);
|
||||
crossSelected = selectedId;
|
||||
fcPreview = null;
|
||||
$('#actions').css('display', 'none');
|
||||
$('#fc_sel').text(crossSelected);
|
||||
$('#fc_do').css('display', 'none');
|
||||
$('#fixcross').css('display', 'block');
|
||||
selectCrossingByRegion(crossSelected);
|
||||
}
|
||||
|
||||
function bFixCrossPreview() {
|
||||
if( fcPreview != null ) {
|
||||
map.removeLayer(fcPreview);
|
||||
fcPreview = null;
|
||||
}
|
||||
$('#fc_do').css('display', 'none');
|
||||
$.ajax(getServer('fixcrossing'), {
|
||||
data: {
|
||||
'preview': 1,
|
||||
'region': crossSelected,
|
||||
'ids': Object.keys(selectedCrossings).join(',')
|
||||
},
|
||||
success: bFixCrossDrawPreview
|
||||
});
|
||||
}
|
||||
|
||||
function bFixCrossDrawPreview(geojson) {
|
||||
if( !('geometry' in geojson) ) {
|
||||
return;
|
||||
}
|
||||
fcPreview = L.geoJson(geojson, {
|
||||
style: function(f) {
|
||||
return { color: 'red', weight: 1, fill: false };
|
||||
}
|
||||
});
|
||||
map.addLayer(fcPreview);
|
||||
$('#fc_do').css('display', 'block');
|
||||
}
|
||||
|
||||
function bFixCrossDo() {
|
||||
$.ajax(getServer('fixcrossing'), {
|
||||
data: {
|
||||
'region': crossSelected,
|
||||
'ids': Object.keys(selectedCrossings).join(',')
|
||||
},
|
||||
success: updateBorders
|
||||
});
|
||||
bFixCrossCancel();
|
||||
}
|
||||
|
||||
function bFixCrossCancel() {
|
||||
if( fcPreview != null ) {
|
||||
map.removeLayer(fcPreview);
|
||||
fcPreview = null;
|
||||
}
|
||||
crossSelected = null;
|
||||
selectCrossingByRegion(false);
|
||||
selectedCrossings = {};
|
||||
updateBorders();
|
||||
$('#actions').css('display', 'block');
|
||||
$('#fixcross').css('display', 'none');
|
||||
}
|
||||
|
||||
function startOver() {
|
||||
if (confirm('Вы уверены, что хотите начать разбиение сначала?')) {
|
||||
finishChooseParent();
|
||||
|
@ -1306,7 +1126,6 @@ function startOver() {
|
|||
bPointCancel();
|
||||
bDivideCancel();
|
||||
bBackupCancel();
|
||||
bFixCrossCancel();
|
||||
selectLayer(null);
|
||||
$('#wait_start_over').show();
|
||||
$.ajax(getServer('start_over'), {
|
||||
|
|
|
@ -23,12 +23,12 @@
|
|||
#info { margin-top: 1em; }
|
||||
#b_delete, #b_clear, .back_del { font-size: 8pt; }
|
||||
#split, #join, #join_to_parent, #point,
|
||||
#divide, #backup, #fixcross { display: none; }
|
||||
#divide, #backup { display: none; }
|
||||
.actions input[type='text'], #search input[type='text'] { width: 150px; }
|
||||
#header { border-bottom: 1px solid gray; margin-bottom: 1em; padding-bottom: 1em; }
|
||||
#f_topo, #f_chars, #f_comments, #links { font-size: 10pt; }
|
||||
#backup_saving, #backup_restoring { margin-bottom: 1em; }
|
||||
#filefm, #old_action, #josm_old, #cross_actions { display: none; }
|
||||
#filefm, #old_action, #josm_old { display: none; }
|
||||
#h_iframe { display: block; width: 100%; height: 80px; }
|
||||
a, a:hover, a:visited { color: blue; }
|
||||
#start_over, #start_over:hover, #start_over:visited { color: red; }
|
||||
|
@ -226,16 +226,6 @@
|
|||
<div id="backup_list"></div>
|
||||
<button onclick="bBackupCancel()">Вернуться</button>
|
||||
</div>
|
||||
<div id="fixcross" class="actions">
|
||||
Границы региона <span id="fc_sel"></span> будут поправлены, чтобы включать в себя подсвеченные красным линии.
|
||||
Кликайте на линии, чтобы изменять их статус.<br>
|
||||
<br>
|
||||
<button onclick="bFixCrossPreview()">Посмотреть, что получится</button><br>
|
||||
<div id="fc_do">
|
||||
<button onclick="bFixCrossDo()">Включить линии в контур</button>
|
||||
</div>
|
||||
<button onclick="bFixCrossCancel()">Вернуться</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
</body>
|
||||
|
|
Loading…
Add table
Reference in a new issue