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"]