Add database, settings, etc; Update templates; Modernize JS

This commit is contained in:
Raoul Snyman 2023-04-12 13:03:01 -07:00
parent 247ed94362
commit 704d52805f
10 changed files with 442 additions and 197 deletions

2
.flake8 Normal file
View File

@ -0,0 +1,2 @@
[flake8]
max-line-length = 120

159
.gitignore vendored Normal file
View File

@ -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

View File

@ -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)

12
mapmakr/database.py Normal file
View File

@ -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

43
mapmakr/models.py Normal file
View File

@ -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

10
mapmakr/settings.py Normal file
View File

@ -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()

View File

@ -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; i<num_objects; i++) {
var gitmap_file = objects[i].file;
var gitmap_object = objects[i].contents;
var marker = L.marker(gitmap_object.location);
for (var opt in Object.keys(gitmap_object.options)) {
marker.options[opt] = gitmap_object.options[opt];
}
var popup = '';
if (gitmap_file.match(/^office/i)) {
marker.options.icon = L.AwesomeMarkers.icon({icon: 'building', prefix: 'fa', markerColor: 'red'});
popup = gitmap_file;
} else {
marker.options.icon = L.AwesomeMarkers.icon({icon: 'person', prefix: 'ion'});
text1 = '<a href="http://rover.redhat.com/people/profile/'+gitmap_file+'" target="_blank">roster</a>';
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 = '<a href="http://leafletjs.com">Leaflet</a>';
sc.setPrefix (req + ' | ' + leaflet);
});
}
function gitmap_setup() {
var osmUrl = 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
osmAttrib = '&copy; <a href="http://openstreetmap.org/copyright">OpenStreetMap</a> 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 = '&copy; <a href="http://openstreetmap.org/copyright">OpenStreetMap</a> 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());
});
});
}

View File

@ -1,13 +1,13 @@
<html><head><title>about gitmap</title></head>
<html><head><title>about mapmakr</title></head>
<body>
<h1>about gitmap</h1>
<h1>about mapmakr</h1>
<p>gitmap is a simple, small, 100% free/open-source, collaborative map
<p>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.</p>
<b><a href="index.html">run gitmap</a></b>
<b><a href="index.html">run mapmakr</a></b>
<h1>FAQ</h1>
@ -18,7 +18,7 @@ installation is for locating Red Hat remotees / offices. Other installations
may store whatever they like.</p>
<h2>Who?</h2>
<p><a href="mailto:fche@redhat.com">fche</a> (gitmap integration scripts),
<p><a href="mailto:fche@redhat.com">fche</a> (mapmakr integration scripts),
<a href="http://www.openstreetmap.org">Open Streetmap</a> (background map),
<a href="http://www.leafletjs.com">Leaflet</a> (map rendering javascript library + plugins),
and others.</p>
@ -55,7 +55,7 @@ Click on doomed markers. Click on "save" or "cancel".</p>
<h2>How does it work?</h2>
<p><a href="https://gitlab.cee.redhat.com/fche/gitmap">Check it out.</a></p>
<p><a href="https://gitlab.cee.redhat.com/fche/mapmakr">Check it out.</a></p>
<p>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.</p>
<h2>Where to send patches?</h2>
<p>Find gitmap sources
at <tt>https://gitlab.cee.redhat.com/fche/gitmap.git</tt>.
<p>Find mapmakr sources
at <tt>https://gitlab.cee.redhat.com/fche/mapmakr.git</tt>.
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 <tt>fche</tt> if desired.</p>
@ -91,7 +91,7 @@ experiment / deploy. Send patches to <tt>fche</tt> if desired.</p>
</ol>
Patches are welcome!</p>
<p>Please note that the version of the gitmap tree
<p>Please note that the version of the mapmakr tree
in <tt>gitlab.cee</tt> contains snapshots of the real live
personal data RH folks have chosen to share. It is obviously
confidential.</p>

View File

@ -1,30 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<title>gitmap editor</title>
<title>{{site_title}}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.5/leaflet.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/0.2.3/leaflet.draw.css" />
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.4.0/css/font-awesome.min.css"/>
<link rel="stylesheet" href="https://code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Leaflet.awesome-markers/2.0.2/leaflet.awesome-markers.css" />
<link rel="stylesheet" href="3rdparty/leaflet-search.css" />
<link rel="stylesheet" href="3rdparty/leaflet-easybutton.css" />
<link rel="stylesheet" href="3rdparty/leaflet-label.css" />
<link rel="stylesheet" href="3rdparty/MarkerCluster.css"/>
<link rel="stylesheet" href="3rdparty/MarkerCluster.Default.css"/>
<link rel="stylesheet" href="{{ url_for('static', path='/3rdparty/leaflet-search.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/3rdparty/leaflet-easybutton.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/3rdparty/leaflet-label.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/3rdparty/MarkerCluster.css') }}"/>
<link rel="stylesheet" href="{{ url_for('static', path='/3rdparty/MarkerCluster.Default.css') }}"/>
</head>
<body>
<div id="mapmakr" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.5/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/0.2.3/leaflet.draw.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Leaflet.awesome-markers/2.0.2/leaflet.awesome-markers.min.js"></script>
<script src="3rdparty/leaflet-search.js"></script>
<script src="3rdparty/leaflet-easybutton.js"></script>
<script src="3rdparty/leaflet-label.js"></script>
<script src="3rdparty/L.Terminator.js"></script>
<script src="3rdparty/leaflet.markercluster.js"></script>
<script src="mapmakr.js"></script>
</head>
<body>
<div id="map" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></div>
<script src="{{ url_for('static', path='/3rdparty/leaflet-search.js') }}"></script>
<script src="{{ url_for('static', path='/3rdparty/leaflet-easybutton.js') }}"></script>
<script src="{{ url_for('static', path='/3rdparty/leaflet-label.js') }}"></script>
<script src="{{ url_for('static', path='/3rdparty/L.Terminator.js') }}"></script>
<script src="{{ url_for('static', path='/3rdparty/leaflet.markercluster.js') }}"></script>
<script src="{{ url_for('static', path='/js/mapmakr.js') }}"></script>
<script>setup_map();</script>
</body>
</html>

View File

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