diff --git a/webapp/config.ini.sample b/webapp/config.ini.sample index 2e3a82b..6af683b 100644 --- a/webapp/config.ini.sample +++ b/webapp/config.ini.sample @@ -1,2 +1,2 @@ [DB] - path = /path/to/ipe.sqlite \ No newline at end of file + axione_ipe_path = /path/to/ipe.sqlite \ No newline at end of file diff --git a/webapp/ipe_fetcher/__init__.py b/webapp/ipe_fetcher/__init__.py index e69de29..b229412 100644 --- a/webapp/ipe_fetcher/__init__.py +++ b/webapp/ipe_fetcher/__init__.py @@ -0,0 +1,2 @@ +from .axione import * +from .liazo import * \ No newline at end of file diff --git a/webapp/ipe_fetcher/axione.py b/webapp/ipe_fetcher/axione.py index 51a8056..75224b7 100644 --- a/webapp/ipe_fetcher/axione.py +++ b/webapp/ipe_fetcher/axione.py @@ -1,8 +1,86 @@ -# const AXIONE_ETAT_DEPLOYE = -# "DEPLOYE" -# const AXIONE_ETAT_DEPLOIEMENT = "EN COURS DE DEPLOIEMENT" -# const AXIONE_ETAT_ABANDONNE = "ABANDONNE" -# const AXIONE_ETAT_CIBLE = "CIBLE" -# const AXIONE_ETAT_SIGNE = "SIGNE" -# const AXIONE_ETAT_RAD_DEPLOIEMENT = "RAD EN COURS DE DEPLOIEMENT" -# const AXIONE_ETAT_RACCORDABLE_DEMANDE = "RACCORDABLE DEMANDE" +from ipe_fetcher.model import AreaCoordinates, Building, FAIEligibilityStatus +from ipe_fetcher.sqlite_connector.cursor import getCursorWithSpatialite +from os.path import exists + +AXIONE_ETAT_DEPLOYE = "DEPLOYE" +AXIONE_ETAT_DEPLOIEMENT = "EN COURS DE DEPLOIEMENT" +AXIONE_ETAT_ABANDONNE = "ABANDONNE" +AXIONE_ETAT_CIBLE = "CIBLE" +AXIONE_ETAT_SIGNE = "SIGNE" +AXIONE_ETAT_RAD_DEPLOIEMENT = "RAD EN COURS DE DEPLOIEMENT" +AXIONE_ETAT_RACCORDABLE_DEMANDE = "RACCORDABLE DEMANDE" + + +class Axione: + def __init__(self, db_axione_ipe_path: str): + self.db_axione_ipe_path = db_axione_ipe_path + # Check at least that the file exists + if not exists(self.db_axione_ipe_path): + raise ValueError(f"File {self.db_axione_ipe_path} does not exist") + + def getAreaBuildings( + self, areaCoordinates: AreaCoordinates, existing_buildings: dict + ) -> dict: + cur = None + # Try to get cursor on Axone database + try: + cur = getCursorWithSpatialite(self.db_axione_ipe_path) + except Exception as err: + print("Error while connecting to DB: ", err) + raise "Could not get Axione data" + # Let's first see how big is the area we're about to query. + # If it's too big, abort the request to prevent a server DOS. + cur.execute( + """ + SELECT Area(BuildMBR(:swx,:swy,:nex,:ney,4326)) + """, + areaCoordinates, + ) + req_area = cur.fetchone()[0] + if req_area <= 0.08: + cur.execute( + """ + SELECT + X(ImmeubleGeoPoint), + Y(ImmeubleGeoPoint), + IdentifiantImmeuble, + EtatImmeuble, + NumeroVoieImmeuble, + TypeVoieImmeuble, + NomVoieImmeuble + FROM ipe + WHERE ROWID IN ( + SELECT ROWID FROM SpatialIndex + WHERE f_table_name = 'ipe' AND + search_frame = BuildMBR(:swx, :swy, :nex, :ney, 4326)) + """, + areaCoordinates, + ) + if not existing_buildings: + existing_buildings = dict() + buildings = existing_buildings + for b in cur.fetchall(): + etatImm = b[3] + idImm = b[2] + isEligible = etatImm == AXIONE_ETAT_DEPLOYE + aquilenetEligStatus = FAIEligibilityStatus( + isEligible=isEligible, + ftthStatus=etatImm, + reasonNotEligible=None if isEligible else "Pas encore deploye", + ) + if buildings.get(idImm): + buildings[idImm]["aquilenetEligStatus"] = aquilenetEligStatus + else: + building = Building( + x=b[0], + y=b[1], + idImm=idImm, + numVoieImm=b[4], + typeVoieImm=b[5], + nomVoieImm=b[6], + aquilenetEligStatus=aquilenetEligStatus, + ) + buildings[idImm] = building + return buildings + else: + raise ValueError("The requested area is too wide, please reduce it") diff --git a/webapp/ipe_fetcher/liazo.py b/webapp/ipe_fetcher/liazo.py new file mode 100644 index 0000000..e58fb4d --- /dev/null +++ b/webapp/ipe_fetcher/liazo.py @@ -0,0 +1,41 @@ +import http.client as httplib +from ipe_fetcher.model import AreaCoordinates, Building, FAIEligibilityStatus +import json + +class Liazo: + def __init__(self): + self.https_conn = httplib.HTTPSConnection("vador.fdn.fr") + + def getAreaBuildings( + self, center_lat: float, center_lng: float, existing_buildings: dict + ) -> dict: + c = self.https_conn + req = "/souscription/gps-batiments.cgi?etape=gps_batiments&lat1=%f&lat2=%f&lon1=%f&lon2=%f" % (center_lat-0.0011, center_lat+0.0011, center_lng-0.0022, center_lng+0.0022) + req = req.replace(" ", "%20") + print("Req FDN with: ", req) + c.request("GET", req) + r = c.getresponse() + if r.status < 200 or r.status >= 300: + print("Erreur de serveur chez FDN. Merci de nous faire remonter le numéro de téléphone qui provoque cette erreur") + return + d = r.read() + c.close() + v = json.loads(d.decode("utf-8")) + if not existing_buildings: + existing_buildings = dict() + buildings = existing_buildings + for building in v: + idImm=building.get('ref') + if not buildings.get(idImm): + + building = Building( + y=building.get('lat'), + x=building.get('lon'), + idImm=idImm, + numVoieImm="", + typeVoieImm="", + nomVoieImm="" + ) + print("add building ", building) + buildings[idImm] = building + return buildings diff --git a/webapp/ipe_fetcher/model.py b/webapp/ipe_fetcher/model.py index 4873c91..310781b 100644 --- a/webapp/ipe_fetcher/model.py +++ b/webapp/ipe_fetcher/model.py @@ -3,7 +3,6 @@ from typing import TypedDict class FAIEligibilityStatus(TypedDict): isEligible: bool - ftthDeployer: str ftthStatus: str reasonNotEligible: str @@ -18,3 +17,10 @@ class Building(TypedDict): aquilenetEligStatus: FAIEligibilityStatus ffdnEligStatus: FAIEligibilityStatus othersEligStatus: FAIEligibilityStatus + + +class AreaCoordinates(TypedDict): + swx: float + swy: float + nex: float + ney: float diff --git a/webapp/ipe_fetcher/sqlite_connector/__init__.py b/webapp/ipe_fetcher/sqlite_connector/__init__.py new file mode 100644 index 0000000..68c4441 --- /dev/null +++ b/webapp/ipe_fetcher/sqlite_connector/__init__.py @@ -0,0 +1 @@ +# from .cursor import * \ No newline at end of file diff --git a/webapp/ipe_fetcher/sqlite_connector/cursor.py b/webapp/ipe_fetcher/sqlite_connector/cursor.py new file mode 100644 index 0000000..b41527f --- /dev/null +++ b/webapp/ipe_fetcher/sqlite_connector/cursor.py @@ -0,0 +1,8 @@ +import sqlite3 + +def getCursorWithSpatialite(db_path: str = None) -> sqlite3.Cursor: + db = sqlite3.connect(db_path) + cur = db.cursor() + db.enable_load_extension(True) + cur.execute('SELECT load_extension("mod_spatialite")') + return cur diff --git a/webapp/main.py b/webapp/main.py index 71462fb..5aea405 100644 --- a/webapp/main.py +++ b/webapp/main.py @@ -4,30 +4,37 @@ from typing import TypedDict import configparser import sqlite3 import os - +from ipe_fetcher import Liazo,Axione class Config(TypedDict): - dbPath: str + axione_ipe_path: str + def parseConfig() -> Config: - cfg_path = os.environ.get("CONFIG", "/etc/ftth-ipe-map/conf.ini") - cfg = configparser.ConfigParser() - with open(cfg_path, "r") as f: - cfg.read_file(f) - return {'dbPath':cfg.get("DB","path")} + cfg_path = os.environ.get("CONFIG", "/etc/ftth-ipe-map/conf.ini") + cfg = configparser.ConfigParser() + with open(cfg_path, "r") as f: + cfg.read_file(f) + return {"axione_ipe_path": cfg.get("DB", "axione_ipe_path")} + app = Flask(__name__) -cfg:Config = parseConfig() +cfg: Config = parseConfig() + +axione = Axione(cfg.get("axione_ipe_path")) +liazo = Liazo() + @app.route("/", methods=["GET"]) def getMap(): return render_template("map.html") + @app.route("/eligdata", methods=["GET"]) def getEligData(): args = request.args valid_args = True processed_args = {} - for k in ['swx', 'swy', 'nex', 'ney']: + for k in ["swx", "swy", "nex", "ney", "centerlat", "centerlng"]: valid_args = valid_args and k in args if valid_args: try: @@ -35,43 +42,14 @@ def getEligData(): except ValueError: valid_args = False if valid_args: - cur = cursorWithSpatialite() - # Let's first see how big is the area we're about to query. - # If it's too big, abort the request to prevent a server DOS. - cur.execute(''' - SELECT Area(BuildMBR(:swx,:swy,:nex,:ney,4326)) - ''',processed_args) - req_area = cur.fetchone()[0] - if req_area <= 0.08: - cur.execute(''' - SELECT - X(ImmeubleGeoPoint), - Y(ImmeubleGeoPoint), - IdentifiantImmeuble, - EtatImmeuble, - NumeroVoieImmeuble, - TypeVoieImmeuble, - NomVoieImmeuble - FROM ipe - WHERE ROWID IN ( - SELECT ROWID FROM SpatialIndex - WHERE f_table_name = 'ipe' AND - search_frame = BuildMBR(:swx, :swy, :nex, :ney, 4326)) - ''',processed_args) - buildings = [ { - 'x':b[0], 'y':b[1], 'idImm':b[2], - 'etatImm':b[3], 'numVoieImm': b[4], - 'typeVoieImm': b[5], 'nomVoieImm': b[6] - } for b in cur.fetchall()] - return { "buildings": buildings} - else: - return "The requested area is too wide, please reduce it", 400 + buildings = dict() + try: + buildings = axione.getAreaBuildings(processed_args, buildings) + except ValueError as err: + print("Could not get Axione data for this area:", err) + + buildings = liazo.getAreaBuildings(processed_args["centerlat"], processed_args["centerlng"], buildings) + + return {"buildings": buildings} else: return "Invalid bounding box coordinates", 400 - -def cursorWithSpatialite(): - db = sqlite3.connect(cfg['dbPath']) - cur = db.cursor() - db.enable_load_extension(True) - cur.execute('SELECT load_extension("mod_spatialite")') - return cur diff --git a/webapp/templates/app.js b/webapp/templates/app.js index b3800b0..d8e394b 100644 --- a/webapp/templates/app.js +++ b/webapp/templates/app.js @@ -1,118 +1,120 @@ const minZoomForRequest = 17; let markers = []; function initMap(btn) { - // Init map position/zoom. Potentially using what's in the URL search string. - const params = new URLSearchParams(window.location.search); - let x = parseFloat(params.get('x')); - let y = parseFloat(params.get('y')); - let z = parseInt(params.get('z')); - let map = L.map('map'); - if(x && y && z) { - map.setView([y, x], z); - fetchEligData(map); - displayBtn(btn); - } else { - map.setView([46.710, 3.669], 6); + // Init map position/zoom. Potentially using what's in the URL search string. + const params = new URLSearchParams(window.location.search); + let x = parseFloat(params.get('x')); + let y = parseFloat(params.get('y')); + let z = parseInt(params.get('z')); + let map = L.map('map'); + if (x && y && z) { + map.setView([y, x], z); + fetchEligData(map); + displayBtn(btn); + } else { + map.setView([46.710, 3.669], 6); + } + + L.tileLayer('https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors' + }).addTo(map); + + map.on("zoom", () => { + /* We only want to enable the search button when we reached a sufficient zoom level */ + if (btn.disabled && map.getZoom() >= minZoomForRequest) { + displayBtn(btn); } - - L.tileLayer('https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors' - }).addTo(map); - - map.on("zoom", () => { - /* We only want to enable the search button when we reached a sufficient zoom level */ - if (btn.disabled && map.getZoom() >= minZoomForRequest) { - displayBtn(btn); - } - if (!btn.disabled && map.getZoom() < minZoomForRequest) { - hideBtn(btn); - } - }); - return map; + if (!btn.disabled && map.getZoom() < minZoomForRequest) { + hideBtn(btn); + } + }); + return map; } function initAddrSearch(map) { - const autocompleteOptions = { - debounceTime: 300, - search: async (query) => { - if(query.length > 2) { - const mapCenter = map.getCenter(); - const reqUri = `https://photon.komoot.io/api/?q=${encodeURI(query)}&lat=${mapCenter.lat}&lon=${mapCenter.lng}&limit=20&lang=fr`; - const source = await fetch(reqUri); - const data = await source.json(); - return data.features; - } else { - return []; - } - }, - renderResult: (res, props) => { - const p = res.properties; - if(p.name && p.postcode && p.city && p.county && res.geometry.coordinates && res.geometry.coordinates.length === 2) - return `
  • ${p.name} - ${p.postcode} ${p.city}, ${p.county}
  • `; - else - return ""; - }, - onSubmit: async (res) => { - const searchInput = document.getElementById('search-addr-autocomplete-input'); - const p = res.properties; - searchInput.value = `${p.name} - ${p.postcode} ${p.city}, ${p.county}`; - // We already filtered out the result not having strictly 2 coordinates at item display - map.setView([res.geometry.coordinates[1],res.geometry.coordinates[0]], 19); - fetchEligData(map); - } - }; - const autocompleteAddr = new Autocomplete("#search-addr-autocomplete", autocompleteOptions); - return autocompleteAddr; + const autocompleteOptions = { + debounceTime: 300, + search: async (query) => { + if (query.length > 2) { + const mapCenter = map.getCenter(); + const reqUri = `https://photon.komoot.io/api/?q=${encodeURI(query)}&lat=${mapCenter.lat}&lon=${mapCenter.lng}&limit=20&lang=fr`; + const source = await fetch(reqUri); + const data = await source.json(); + return data.features; + } else { + return []; + } + }, + renderResult: (res, props) => { + const p = res.properties; + if (p.name && p.postcode && p.city && p.county && res.geometry.coordinates && res.geometry.coordinates.length === 2) + return `
  • ${p.name} - ${p.postcode} ${p.city}, ${p.county}
  • `; + else + return ""; + }, + onSubmit: async (res) => { + const searchInput = document.getElementById('search-addr-autocomplete-input'); + const p = res.properties; + searchInput.value = `${p.name} - ${p.postcode} ${p.city}, ${p.county}`; + // We already filtered out the result not having strictly 2 coordinates at item display + map.setView([res.geometry.coordinates[1], res.geometry.coordinates[0]], 19); + fetchEligData(map); + } + }; + const autocompleteAddr = new Autocomplete("#search-addr-autocomplete", autocompleteOptions); + return autocompleteAddr; } function updateEligData(map, eligData) { - markers.map(marker => map.removeLayer(marker)); - let buildings = eligData.buildings; - markers = buildings.map(building => { - const latlng = new L.latLng(building.y, building.x); - const addrImm = `${building.numVoieImm} ${building.typeVoieImm} ${building.nomVoieImm}` - const marker = new L.marker(latlng) - .bindPopup(`${addrImm}
    Etat: ${building.etatImm}
    Code Immeuble: ${building.idImm}`); - map.addLayer(marker); - return marker - }); + markers.map(marker => map.removeLayer(marker)); + let buildings = eligData.buildings; + console.log(buildings) + markers = Object.values(buildings).map(building => { + const latlng = new L.latLng(building.y, building.x); + const addrImm = `${building.numVoieImm} ${building.typeVoieImm} ${building.nomVoieImm}` + const marker = new L.marker(latlng) + .bindPopup(`${addrImm}
    Etat: ${building.etatImm}
    Code Immeuble: ${building.idImm}`); + map.addLayer(marker); + return marker + }); } function updateUrl(map) { - const c = map.getCenter(); - history.replaceState({}, "", encodeURI(`?x=${c.lng}&y=${c.lat}&z=${map.getZoom()}`)); + const c = map.getCenter(); + history.replaceState({}, "", encodeURI(`?x=${c.lng}&y=${c.lat}&z=${map.getZoom()}`)); } async function fetchEligData(map) { - const zoom = map.getZoom(); - if (zoom >= minZoomForRequest) { - const bounds = map.getBounds(); - const sw = bounds.getSouthWest(); - const ne = bounds.getNorthEast(); - const reqUri = encodeURI(`eligdata?swx=${sw.lng}&swy=${sw.lat}&nex=${ne.lng}&ney=${ne.lat}`); - const source = await fetch(reqUri); - const eligData = await source.json(); - updateEligData(map, eligData); - updateUrl(map); - } + const zoom = map.getZoom(); + if (zoom >= minZoomForRequest) { + const mc = map.getCenter(); + const bounds = map.getBounds(); + const sw = bounds.getSouthWest(); + const ne = bounds.getNorthEast(); + const reqUri = encodeURI(`eligdata?swx=${sw.lng}&swy=${sw.lat}&nex=${ne.lng}&ney=${ne.lat}¢erlat=${mc.lat}¢erlng=${mc.lng}`); + const source = await fetch(reqUri); + const eligData = await source.json(); + updateEligData(map, eligData); + updateUrl(map); + } } function initBtn() { - const btn = document.getElementById("btn-load-elig-data"); - btn.disabled = true; - btn.title = "Veuillez zoomer plus la carte avant de lancer une recherche d'éligibilité."; - btn.onclick = () => fetchEligData(map); - return btn; + const btn = document.getElementById("btn-load-elig-data"); + btn.disabled = true; + btn.title = "Veuillez zoomer plus la carte avant de lancer une recherche d'éligibilité."; + btn.onclick = () => fetchEligData(map); + return btn; } function displayBtn(btn) { - btn.disabled = false; - btn.title = "Rechercher les données d'éligibilité pour cette zone." + btn.disabled = false; + btn.title = "Rechercher les données d'éligibilité pour cette zone." } function hideBtn(btn) { - btn.disabled = true; - btn.title = "Veuillez zoomer plus la carte avant de lancer une recherche d'éligibilité."; + btn.disabled = true; + btn.title = "Veuillez zoomer plus la carte avant de lancer une recherche d'éligibilité."; }