From 704d52805fc3421813f4c567121933f50f8f5d32 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Wed, 12 Apr 2023 13:03:01 -0700 Subject: [PATCH] Add database, settings, etc; Update templates; Modernize JS --- .flake8 | 2 + .gitignore | 159 +++++++++++++++++++ mapmakr/app.py | 65 +++++++- mapmakr/database.py | 12 ++ mapmakr/models.py | 43 +++++ mapmakr/settings.py | 10 ++ mapmakr/static/js/mapmakr.js | 294 +++++++++++++++-------------------- mapmakr/templates/about.html | 18 +-- mapmakr/templates/index.html | 31 ++-- pyproject.toml | 5 +- 10 files changed, 442 insertions(+), 197 deletions(-) create mode 100644 .flake8 create mode 100644 .gitignore create mode 100644 mapmakr/database.py create mode 100644 mapmakr/models.py create mode 100644 mapmakr/settings.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..6deafc2 --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 120 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..888fa24 --- /dev/null +++ b/.gitignore @@ -0,0 +1,159 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +.idea/ + +# Local test DB +mapmakr.sqlite diff --git a/mapmakr/app.py b/mapmakr/app.py index 6fc2f5c..acd7804 100644 --- a/mapmakr/app.py +++ b/mapmakr/app.py @@ -1,15 +1,70 @@ from pathlib import Path +from typing import List -from fastapi import FastAPI +from fastapi import Depends, FastAPI, HTTPException, Request, Response from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from sqlmodel import Session, select + +from mapmakr.models import Marker, MarkerRead, MarkerCreate, MarkerUpdate, create_db_tables, \ + get_session +from mapmakr.settings import settings + ROOT = Path(__file__).parent + app = FastAPI() -app.mount('/static', StaticFiles(directory='static'), name='static') +app.mount('/static', StaticFiles(directory=str(ROOT / 'static')), name='static') +templates = Jinja2Templates(directory=str(ROOT / 'templates')) + + +@app.on_event('startup') +def on_startup(): + create_db_tables() @app.get('/', response_class=HTMLResponse) -async def index(): - content = (ROOT / 'templates' / 'index.html').open().read() - return HTMLResponse(content=content, status_code=200) +async def index(request: Request): + return templates.TemplateResponse('index.html', {'request': request, + 'site_title': settings.site_title}) + + +@app.post('/marker', tags=['markers'], response_model=MarkerRead) +async def create_marker(marker: MarkerCreate, session: Session = Depends(get_session)) -> Marker: + db_marker = Marker.from_orm(marker) + session.add(db_marker) + session.commit() + session.refresh(db_marker) + return db_marker + + +@app.patch('/marker/{marker_id}', tags=['markers'], response_model=MarkerRead) +async def update_marker(marker_id: int, marker: MarkerUpdate, + session: Session = Depends(get_session)) -> Marker: + db_marker = session.get(Marker, marker_id) + if not db_marker: + raise HTTPException(status_code=404, detail='Marker not found') + marker_data = marker.dict(exclude_unset=True) + for key, value in marker_data.items(): + setattr(db_marker, key, value) + session.add(db_marker) + session.commit() + session.refresh(db_marker) + return db_marker + + +@app.get('/marker', tags=['markers'], response_model=List[MarkerRead]) +async def read_markers(session: Session = Depends(get_session)) -> list[MarkerRead]: + markers = session.exec(select(Marker)).all() + return markers + + +@app.delete('/marker/{marker_id}', tags=['markers']) +async def delete_marker(marker_id: int, session: Session = Depends(get_session)): + db_marker = session.get(Marker, marker_id) + if not db_marker: + raise HTTPException(status_code=404, detail='Marker not found') + session.delete(db_marker) + session.commit() + return Response(status_code=200) diff --git a/mapmakr/database.py b/mapmakr/database.py new file mode 100644 index 0000000..9d37ed0 --- /dev/null +++ b/mapmakr/database.py @@ -0,0 +1,12 @@ +from sqlmodel import create_engine + +from mapmakr.settings import settings + +_engine = None + + +def get_engine(): + global _engine + if not _engine: + _engine = create_engine(settings.database_uri, echo=settings.database_echo) + return _engine diff --git a/mapmakr/models.py b/mapmakr/models.py new file mode 100644 index 0000000..51e6abd --- /dev/null +++ b/mapmakr/models.py @@ -0,0 +1,43 @@ +from decimal import Decimal +from typing import Any, Dict, Optional + +from sqlalchemy.schema import Column +from sqlalchemy.types import JSON +from sqlmodel import Field, SQLModel, Session + +from mapmakr.database import get_engine + + +class MarkerBase(SQLModel): + name: str + longitude: Decimal = Field(default=0) + latitude: Decimal = Field(default=0) + options: Dict[str, Any] = Field(sa_column=Column(JSON)) + + +class Marker(MarkerBase, table=True): # type: ignore[call-arg] + id: Optional[int] = Field(default=None, primary_key=True) + + +class MarkerCreate(MarkerBase): + pass + + +class MarkerRead(MarkerBase): + id: int + + +class MarkerUpdate(SQLModel): + name: Optional[str] = None + longitude: Optional[Decimal] = None + latitude: Optional[Decimal] = None + options: Optional[Dict[str, Any]] = None + + +def create_db_tables(): + SQLModel.metadata.create_all(get_engine()) + + +def get_session(): + with Session(get_engine()) as session: + yield session diff --git a/mapmakr/settings.py b/mapmakr/settings.py new file mode 100644 index 0000000..bbaab8e --- /dev/null +++ b/mapmakr/settings.py @@ -0,0 +1,10 @@ +from pydantic import BaseSettings + + +class Settings(BaseSettings): + database_uri: str = 'sqlite:///mapmakr.sqlite' + database_echo: bool = False + site_title: str = 'mapmakr' + + +settings = Settings() diff --git a/mapmakr/static/js/mapmakr.js b/mapmakr/static/js/mapmakr.js index 87ddfb4..21ef09c 100644 --- a/mapmakr/static/js/mapmakr.js +++ b/mapmakr/static/js/mapmakr.js @@ -1,172 +1,136 @@ -/* */ +let map, drawnItems, drawControl; -var map; -var drawnItems; -var statsControl; -var drawControl; - - -// http://stackoverflow.com/a/18324384/661150 -function callAjax(url, callback) -{ - var xmlhttp; - // compatible with IE7+, Firefox, Chrome, Opera, Safari - xmlhttp = new XMLHttpRequest(); - xmlhttp.onreadystatechange = function(){ - if (xmlhttp.readyState == 4 && xmlhttp.status == 200){ - callback(xmlhttp.responseText); - } - } - xmlhttp.open("GET", url, true); - xmlhttp.send(); -} - - -function gitmap_reload() { - callAjax("read.cgi", (function(req) { - var map = window.map; - drawnItems.clearLayers(); - - var objects = JSON.parse(req).objects; - var num_objects = objects.length; - for (var i=0; iroster'; - text2 = ''; - popup = gitmap_file+' '+text1+' '+text2; - } - marker.options.id=gitmap_file; - marker.bindLabel(gitmap_file, { noHide: true }); - marker.bindPopup(popup); - drawnItems.addLayer(marker); - } - - if (drawControl == null) { +function reload_map() { + fetch("/marker").then(response => response.json()).then(markers => { + // const map = window.map; + drawnItems.clearLayers(); + markers.forEach(marker => { + let mapMarker = L.marker({"lat": marker.latitude, "lng": marker.longitude}); + mapMarker.options["id"] = marker.id; + mapMarker.options["title"] = marker.name; + for (const opt in marker.options) { + mapMarker.options[opt] = marker.options[opt]; + } + mapMarker.bindLabel(marker.name, {noHide: true}); + mapMarker.bindPopup(marker.name); + drawnItems.addLayer(mapMarker); + }); + if (!drawControl) { // add drawControl only after some drawnItems exist, so // it is not shown disabled drawControl = new L.Control.Draw({ - draw: { - position: 'topleft', - polygon: false, - rectangle: false, - polyline: false, - circle: false - }, - edit: { - featureGroup: drawnItems - } - }); - map.addControl(drawControl); - } - })); - callAjax("summary.cgi", function(req) { - var sc = window.statsControl; - var leaflet = 'Leaflet'; - sc.setPrefix (req + ' | ' + leaflet); - }); -} - - -function gitmap_setup() { - var osmUrl = 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - osmAttrib = '© OpenStreetMap contributors', - osm = L.tileLayer(osmUrl, {maxZoom: 18, attribution: osmAttrib}); - - map = new L.Map('map', {layers: [osm], attributionControl: false, center: new L.LatLng(0, 0), zoom: 2 }); - - drawnItems = new L.MarkerClusterGroup({maxClusterRadius: 45}); // new L.FeatureGroup(); - map.addLayer(drawnItems); - gitmap_reload(); - - // drawcontrol formerly added here - - var refreshButton = new L.easyButton('icon ion-refresh', function() {gitmap_reload();}); - refreshButton.button.style.fontSize = '18px'; - map.addControl (refreshButton); - - var helpButton = new L.easyButton('icon ion-help-circled', function() {open("about.html");}); - helpButton.button.style.fontSize = '18px'; - map.addControl (helpButton); - - var controlSearch = new L.Control.Search({layer: drawnItems, - propertyName: 'id', - initial: false, - zoom: 12}); - map.addControl( controlSearch ); - - statsControl = new L.control.attribution({position:'bottomright', prefix:''}); - map.addControl(statsControl); - - scaleControl = new L.control.scale(); - map.addControl(scaleControl); - - terminator = new L.terminator(); - terminator.options.opacity=0.2; - terminator.options.fillOpacity=0.2; - terminator.addTo(map); - setInterval(function() {updateTerminator(terminator);}, 60000); // 1 minute - function updateTerminator(t) { - var t2 = L.terminator(); - t.setLatLngs(t2.getLatLngs()); - t.redraw(); - } - - // called after new marker is created - map.on('draw:created', function (e) { - var type = e.layerType, - layer = e.layer; - if (type !== 'marker') return; - - var userid = prompt("Marker name (e.g., kerberos userid):", ""); - if (userid != null) { - layer.options.id=userid; - layer.bindPopup(userid); - drawnItems.addLayer(layer); - callAjax("write.cgi?op=new"+ - "&id="+encodeURIComponent(userid)+ - "&location="+encodeURIComponent(JSON.stringify(layer.getLatLng()))+ - "&options="+encodeURIComponent("{}"), - function(req){alert("response:\n"+req); - gitmap_reload();}); + draw: { + position: 'topleft', + polygon: false, + rectangle: false, + polyline: false, + circle: false + }, + edit: { + featureGroup: drawnItems } - }); - - map.on('draw:edited', function (e) { - e.layers.eachLayer(function (layer) { - var type = layer.layerType; - var userid = layer.options.id; - callAjax("write.cgi?op=edit"+ - "&id="+encodeURIComponent(userid)+ - "&location="+encodeURIComponent(JSON.stringify(layer.getLatLng()))+ - "&options="+encodeURIComponent("{}"), - function(req){alert("response:\n"+req); - gitmap_reload();}); - }); - }); - - map.on('draw:deleted', function (e) { - e.layers.eachLayer(function (layer) { - var type = layer.layerType; - var userid = layer.options.id; - callAjax("write.cgi?op=delete"+ - "&id="+encodeURIComponent(userid), - function(req){alert("response:\n"+req); - gitmap_reload();}); - }); - }); - - - + }); + console.log(map); + console.log(drawControl); + map.addControl(drawControl); + } + }); +} + +function setup_map() { + let osmUrl = 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + osmAttrib = '© OpenStreetMap contributors', + osm = L.tileLayer(osmUrl, {maxZoom: 18, attribution: osmAttrib}); + + map = new L.Map('mapmakr', {layers: [osm], attributionControl: false, center: new L.LatLng(0, 0), zoom: 2 }); + + drawnItems = new L.MarkerClusterGroup({maxClusterRadius: 45}); + map.addLayer(drawnItems); + reload_map(); + + const refreshButton = new L.easyButton('icon ion-refresh', function() {reload_map();}); + refreshButton.button.style.fontSize = '18px'; + map.addControl(refreshButton); + + const helpButton = new L.easyButton('icon ion-help-circled', function() {open("about.html");}); + helpButton.button.style.fontSize = '18px'; + map.addControl(helpButton); + + const controlSearch = new L.Control.Search({layer: drawnItems, propertyName: 'id', initial: false, zoom: 12}); + map.addControl(controlSearch); + + statsControl = new L.control.attribution({position:'bottomright', prefix:''}); + map.addControl(statsControl); + + scaleControl = new L.control.scale(); + map.addControl(scaleControl); + + terminator = new L.terminator(); + terminator.options.opacity = 0.2; + terminator.options.fillOpacity = 0.2; + terminator.addTo(map); + setInterval(() => updateTerminator(terminator), 60000); // 1 minute + + function updateTerminator(t) { + var t2 = L.terminator(); + t.setLatLngs(t2.getLatLngs()); + t.redraw(); + } + + // called after new marker is created + map.on('draw:created', function (e) { + let type = e.layerType, layer = e.layer; + if (type !== 'marker') { + return; + } + + let name = prompt("Marker name:", ""); + if (name) { + layer.options.id = null; + layer.options.title = name; + layer.bindPopup(name); + drawnItems.addLayer(layer); + let requestOptions = { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + "name": name, + "latitude": layer.getLatLng()["lat"], + "longitude": layer.getLatLng()["lng"], + "options": {} + }) + }; + fetch("/marker", requestOptions).then(() => reload_map()); + } + }); + + map.on('draw:edited', function (e) { + e.layers.eachLayer(layer => { + let type = layer.layerType, id = layer.options.id; + let requestOptions = { + method: "PATCH", + headers: { + "Accept": "application/json", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + "name": layer.options.name, + "latitude": layer.getLatLng()["lat"], + "longitude": layer.getLatLng()["lng"] + }) + }; + fetch("/marker/" + id, requestOptions).then(() => reload_map()); + }); + }); + + map.on('draw:deleted', function (e) { + e.layers.eachLayer(layer => { + let type = layer.layerType, id = layer.options.id; + fetch("/marker/" + id, {method: "DELETE"}).then(() => reload_map()); + }); + }); } diff --git a/mapmakr/templates/about.html b/mapmakr/templates/about.html index ed956be..3b027c5 100644 --- a/mapmakr/templates/about.html +++ b/mapmakr/templates/about.html @@ -1,13 +1,13 @@ -about gitmap +about mapmakr -

about gitmap

+

about mapmakr

-

gitmap is a simple, small, 100% free/open-source, collaborative map +

mapmakr is a simple, small, 100% free/open-source, collaborative map editor that runs mostly in a web browser. It integrates community FOSS projects and data sources.

-run gitmap +run mapmakr

FAQ

@@ -18,7 +18,7 @@ installation is for locating Red Hat remotees / offices. Other installations may store whatever they like.

Who?

-

fche (gitmap integration scripts), +

fche (mapmakr integration scripts), Open Streetmap (background map), Leaflet (map rendering javascript library + plugins), and others.

@@ -55,7 +55,7 @@ Click on doomed markers. Click on "save" or "cancel".

How does it work?

-

Check it out.

+

Check it out.

The front-end consists of very small HTML + JavaScript files that load Leaflet and OSM data from the Internet into your browser. The front-end also loads marker data from our own servers via HTTP CGI. @@ -77,8 +77,8 @@ the job, and easily.

Where to send patches?

-

Find gitmap sources -at https://gitlab.cee.redhat.com/fche/gitmap.git. +

Find mapmakr sources +at https://gitlab.cee.redhat.com/fche/mapmakr.git. One or two third-party javascript libraries are stored there; others are served from upstream projects' external CDNs. Feel free to fork / experiment / deploy. Send patches to fche if desired.

@@ -91,7 +91,7 @@ experiment / deploy. Send patches to fche if desired.

Patches are welcome!

-

Please note that the version of the gitmap tree +

Please note that the version of the mapmakr tree in gitlab.cee contains snapshots of the real live personal data RH folks have chosen to share. It is obviously confidential.

diff --git a/mapmakr/templates/index.html b/mapmakr/templates/index.html index f7e9f57..b8cc5a6 100644 --- a/mapmakr/templates/index.html +++ b/mapmakr/templates/index.html @@ -1,30 +1,29 @@ - gitmap editor + {{site_title}} - - - - - - + + + + + + + +
- - - - - - - - -
+ + + + + + diff --git a/pyproject.toml b/pyproject.toml index b700890..3d17c2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "mapmakr" description = 'An interactive map where you can place your own markers' -readme = "README.md" +readme = "README.rst" requires-python = ">=3.7" license = "MIT" keywords = [] @@ -26,7 +26,8 @@ classifiers = [ dependencies = [ "FastAPI", "Jinja2", - "uvicorn[standard]" + "uvicorn[standard]", + "SQLModel", ] dynamic = ["version"]