etape laptop

This commit is contained in:
2026-02-09 00:01:29 +01:00
commit 805fef0cdc
144 changed files with 15295 additions and 0 deletions

9
.env Normal file
View File

@@ -0,0 +1,9 @@
# Ports
FRONTEND_PORT=8880
BACKEND_PORT=8800
# Upload
MAX_UPLOAD_SIZE=52428800
# Chemins
DATA_DIR=/data

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Ports
FRONTEND_PORT=8080
BACKEND_PORT=8000
# Upload
MAX_UPLOAD_SIZE=52428800
# Chemins
DATA_DIR=/data

80
CLAUDE.md Normal file
View File

@@ -0,0 +1,80 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
WebCarto is a self-hosted web application for displaying and editing cartographic data (KML/GeoJSON files) with satellite/hybrid/vector map backgrounds. Deployed via Docker. UI uses "gruvbox dark vintage" theme. Specification in `consigne.md`.
## Tech Stack
- **Frontend**: Vite + React + TypeScript, MapLibre GL JS, TailwindCSS v4, Zustand, @tmcw/togeojson, DOMPurify
- **Backend**: Python FastAPI, Pydantic, SQLite via SQLModel, uvicorn
- **Deploy**: Docker Compose — 2 services: backend (FastAPI + /data volume), frontend (Vite build + Nginx)
## Architecture
```
frontend/ → React SPA (Vite)
src/components/ → MapView, Header, LayerPanel, PropertyPanel, ImportDialog, ToastContainer, StatusBar
src/stores/mapStore → Zustand store (datasets, features, selection, undo, toasts)
src/api/client → API client (fetch wrapper)
backend/ → FastAPI REST API
app/main.py → FastAPI app + CORS + startup
app/models.py → SQLModel models: Dataset, Feature, FeatureVersion
app/routes/datasets → Import, list, get, export
app/routes/features → Update feature (with versioning)
app/config.py → DATA_DIR, DATABASE_URL, MAX_UPLOAD_SIZE
app/database.py → Engine + session dependency
tests/test_api.py → 9 tests (health, import, CRUD, export, 404)
samples/ → example.geojson, example.kml
```
## Development Commands
### Docker
```bash
docker-compose up --build # Lancer le stack complet
docker-compose down # Arrêter
```
### Frontend (from `frontend/`)
```bash
npm install # Installer les dépendances
npm run dev # Serveur dev (Vite, port 5173, proxy /api → backend:8000)
npm run build # Build production (tsc + vite)
npx tsc -b # Vérification TypeScript seule
npm run lint # ESLint
```
### Backend (from `backend/`)
```bash
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt pytest httpx # Setup
.venv/bin/uvicorn app.main:app --reload # Dev server
.venv/bin/pytest tests/ -v # Tous les tests
.venv/bin/pytest tests/test_api.py -k test_import # Un test spécifique
```
## Key Design Decisions
- **KML parsing côté frontend** (@tmcw/togeojson), envoyé au backend en GeoJSON normalisé
- **Fonds de carte** définis dans MapView.tsx (OSM raster, Esri satellite, Stamen labels). 3 modes: vector/satellite/hybrid
- **Versioning**: table `feature_versions` stocke before/after JSON pour chaque modification
- **Pas d'auth en v1** — accès LAN uniquement, structure prête pour OIDC/reverse proxy
- **En dev local**, le backend stocke dans `backend/data/` (en Docker: volume `/data`)
- **Tests backend** utilisent SQLite in-memory avec `StaticPool` pour l'isolation
## API Endpoints
```
GET /api/health # Health check
POST /api/datasets/import # Import (multipart: file + geojson)
GET /api/datasets # Lister les datasets
GET /api/datasets/{id} # Dataset + features
PUT /api/features/{id} # Update geometry/properties (avec versioning)
POST /api/datasets/{id}/export?format=geojson # Export GeoJSON
```
## Language
All user-facing text, comments in code, and commit messages should be in French. Technical identifiers (variable names, API paths) stay in English.

10
amelioration.md Normal file
View File

@@ -0,0 +1,10 @@
- [x] ajout des carte googles maps ( satellite, hybrid, vector ) en plus de openstreet maps
- [x] possibilité d'edition d'un import kml ( visualisation du contenu dans volet droit)
- [x] possibilité de selectionner/deplacer un objet sur la carte (surligne dans volet gauche et affiche detail dans volet droit) avec une selection + clicquer/deplacer
- [x] extraction et affichage des images contenues dans un fichier kml
- [x] ajoute bouton pour supprimer un objet kml ou ses objet ( volet gauche)
- [x] enregistre la postion et le niveau de zoom de sorte que pour toute nouvelle connexion on retrouve l'emplacement et le meme niveau de zoom
- [x] lors du rechargement de la page, les objets ne sont pas affiché ?
- [x] un clic sur l'image affiche un popup avec l'image en grand
- [x] ajoute une icon oeil, pour afficher ou masquer (oeil barre) un fichier d'objet kml, oubien un ou des objets de ce fichier ( afficher l'oeil dans le volet gauche)
- [ ] avec les touche clavier haut et bas permet de selection l objet suivant ou precedent

14
backend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/app/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

9
backend/app/config.py Normal file
View File

@@ -0,0 +1,9 @@
import os
from pathlib import Path
_default_data = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
DATA_DIR = Path(os.getenv("DATA_DIR", _default_data))
DATA_DIR.mkdir(parents=True, exist_ok=True)
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATA_DIR / 'webcarto.db'}")
MAX_UPLOAD_SIZE = int(os.getenv("MAX_UPLOAD_SIZE", 50 * 1024 * 1024)) # 50 Mo

13
backend/app/database.py Normal file
View File

@@ -0,0 +1,13 @@
from sqlmodel import SQLModel, create_engine, Session
from .config import DATABASE_URL
engine = create_engine(DATABASE_URL, echo=False)
def init_db():
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session

33
backend/app/main.py Normal file
View File

@@ -0,0 +1,33 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.formparsers import MultiPartParser
from .database import init_db
from .config import MAX_UPLOAD_SIZE
from .routes import datasets, features, images, settings
# Relever la limite de taille des parts multipart (défaut Starlette: 1 Mo)
MultiPartParser.max_part_size = MAX_UPLOAD_SIZE
app = FastAPI(title="WebCarto API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(datasets.router, prefix="/api")
app.include_router(features.router, prefix="/api")
app.include_router(images.router, prefix="/api")
app.include_router(settings.router, prefix="/api")
@app.on_event("startup")
def on_startup():
init_db()
@app.get("/api/health")
def health():
return {"status": "ok"}

42
backend/app/models.py Normal file
View File

@@ -0,0 +1,42 @@
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import SQLModel, Field, Column
import sqlalchemy as sa
class Dataset(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
raw_filename: str
feature_count: int = 0
bbox_json: Optional[str] = None # JSON string [minLng, minLat, maxLng, maxLat]
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column=Column(sa.DateTime(timezone=True), default=sa.func.now()),
)
class Feature(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
dataset_id: int = Field(foreign_key="dataset.id", index=True)
geometry_json: str # GeoJSON geometry as JSON string
properties_json: str # GeoJSON properties as JSON string
class FeatureVersion(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
feature_id: int = Field(foreign_key="feature.id", index=True)
before_json: str
after_json: str
timestamp: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column=Column(sa.DateTime(timezone=True), default=sa.func.now()),
)
class MapSettings(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
center_lng: float = 2.35
center_lat: float = 48.85
zoom: float = 5.0
base_layer: str = "vector"

View File

Binary file not shown.

View File

@@ -0,0 +1,296 @@
import json
import shutil
import xml.etree.ElementTree as ET
import re
import base64
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
from sqlmodel import Session, select
from ..database import get_session
from ..models import Dataset, Feature, FeatureVersion
from ..config import DATA_DIR, MAX_UPLOAD_SIZE
from .images import extract_and_save_images, IMAGES_DIR
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/datasets", tags=["datasets"])
@router.get("")
def list_datasets(session: Session = Depends(get_session)):
datasets = session.exec(select(Dataset)).all()
results = []
for ds in datasets:
bbox = json.loads(ds.bbox_json) if ds.bbox_json else None
results.append({
"id": ds.id,
"name": ds.name,
"feature_count": ds.feature_count,
"created_at": ds.created_at.isoformat(),
"bbox": bbox,
})
return results
@router.get("/{dataset_id}")
def get_dataset(dataset_id: int, session: Session = Depends(get_session)):
ds = session.get(Dataset, dataset_id)
if not ds:
raise HTTPException(404, "Dataset non trouvé")
features = session.exec(
select(Feature).where(Feature.dataset_id == dataset_id)
).all()
bbox = json.loads(ds.bbox_json) if ds.bbox_json else None
return {
"id": ds.id,
"name": ds.name,
"feature_count": ds.feature_count,
"created_at": ds.created_at.isoformat(),
"bbox": bbox,
"raw_filename": ds.raw_filename,
"features": [
{
"id": f.id,
"geometry": json.loads(f.geometry_json),
"properties": json.loads(f.properties_json),
}
for f in features
],
}
@router.delete("/{dataset_id}")
def delete_dataset(dataset_id: int, session: Session = Depends(get_session)):
ds = session.get(Dataset, dataset_id)
if not ds:
raise HTTPException(404, "Dataset non trouvé")
# Supprimer les versions de toutes les features
features = session.exec(
select(Feature).where(Feature.dataset_id == dataset_id)
).all()
for f in features:
versions = session.exec(
select(FeatureVersion).where(FeatureVersion.feature_id == f.id)
).all()
for v in versions:
session.delete(v)
session.delete(f)
# Supprimer le dossier images
img_dir = IMAGES_DIR / str(dataset_id)
if img_dir.exists():
shutil.rmtree(img_dir)
# Supprimer le fichier raw
raw_path = DATA_DIR / "raw" / ds.raw_filename
if raw_path.exists():
raw_path.unlink()
session.delete(ds)
session.commit()
return {"ok": True}
@router.post("/import")
async def import_dataset(
file: UploadFile = File(...),
geojson: str = Form(...),
session: Session = Depends(get_session),
):
# Sauvegarder le fichier brut
raw_dir = DATA_DIR / "raw"
raw_dir.mkdir(exist_ok=True)
content = await file.read()
raw_path = raw_dir / file.filename
# Éviter les écrasements
counter = 1
while raw_path.exists():
stem = Path(file.filename).stem
suffix = Path(file.filename).suffix
raw_path = raw_dir / f"{stem}_{counter}{suffix}"
counter += 1
raw_path.write_bytes(content)
# Parser le GeoJSON
try:
fc = json.loads(geojson)
except json.JSONDecodeError:
raise HTTPException(400, "GeoJSON invalide")
if fc.get("type") != "FeatureCollection":
raise HTTPException(400, "Le JSON doit être un FeatureCollection")
features_data = fc.get("features", [])
# Calculer la bbox
bbox = _compute_bbox(features_data)
# Créer le dataset
ds = Dataset(
name=Path(file.filename).stem,
raw_filename=raw_path.name,
feature_count=len(features_data),
bbox_json=json.dumps(bbox) if bbox else None,
)
session.add(ds)
session.commit()
session.refresh(ds)
# Créer les features
for i, f_data in enumerate(features_data):
geometry = f_data.get("geometry", {})
properties = f_data.get("properties", {})
# Extraire les éventuelles images base64 inline (envoyées dans le JSON)
properties = extract_and_save_images(properties, ds.id, i)
feature = Feature(
dataset_id=ds.id,
geometry_json=json.dumps(geometry),
properties_json=json.dumps(properties),
)
session.add(feature)
session.commit()
# Si KML, extraire les images base64 depuis le fichier brut
if file.filename and file.filename.lower().endswith(".kml"):
_extract_kml_images(raw_path, ds.id, session)
bbox_out = json.loads(ds.bbox_json) if ds.bbox_json else None
return {
"id": ds.id,
"name": ds.name,
"feature_count": ds.feature_count,
"created_at": ds.created_at.isoformat(),
"bbox": bbox_out,
}
@router.post("/{dataset_id}/export")
def export_dataset(dataset_id: int, format: str = "geojson", session: Session = Depends(get_session)):
ds = session.get(Dataset, dataset_id)
if not ds:
raise HTTPException(404, "Dataset non trouvé")
features = session.exec(
select(Feature).where(Feature.dataset_id == dataset_id)
).all()
fc = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": json.loads(f.geometry_json),
"properties": json.loads(f.properties_json),
}
for f in features
],
}
from fastapi.responses import Response
return Response(
content=json.dumps(fc, ensure_ascii=False, indent=2),
media_type="application/geo+json",
headers={"Content-Disposition": f'attachment; filename="{ds.name}.geojson"'},
)
def _compute_bbox(features: list) -> list | None:
coords = []
for f in features:
_extract_coords(f.get("geometry", {}), coords)
if not coords:
return None
lngs = [c[0] for c in coords]
lats = [c[1] for c in coords]
return [min(lngs), min(lats), max(lngs), max(lats)]
def _extract_coords(geometry: dict, coords: list):
gtype = geometry.get("type", "")
coordinates = geometry.get("coordinates")
if not coordinates:
return
if gtype == "Point":
coords.append(coordinates)
elif gtype in ("MultiPoint", "LineString"):
coords.extend(coordinates)
elif gtype in ("MultiLineString", "Polygon"):
for ring in coordinates:
coords.extend(ring)
elif gtype == "MultiPolygon":
for polygon in coordinates:
for ring in polygon:
coords.extend(ring)
elif gtype == "GeometryCollection":
for g in geometry.get("geometries", []):
_extract_coords(g, coords)
def _extract_kml_images(kml_path: Path, dataset_id: int, session: Session):
"""Extraire les images base64 des gx:imageUrl du fichier KML brut
et les associer aux features correspondantes par index de Placemark."""
try:
tree = ET.parse(kml_path)
except ET.ParseError as e:
logger.warning(f"Impossible de parser le KML {kml_path}: {e}")
return
root = tree.getroot()
ns = {
"kml": "http://www.opengis.net/kml/2.2",
"gx": "http://www.google.com/kml/ext/2.2",
}
placemarks = root.findall(".//kml:Placemark", ns)
features = session.exec(
select(Feature).where(Feature.dataset_id == dataset_id)
).all()
if len(placemarks) != len(features):
logger.warning(
f"KML {kml_path}: {len(placemarks)} placemarks vs {len(features)} features, "
"extraction images par index impossible"
)
return
img_dir = IMAGES_DIR / str(dataset_id)
img_dir.mkdir(parents=True, exist_ok=True)
data_uri_re = re.compile(r"data:image/(\w+);base64,(.+)", re.DOTALL)
for i, (pm, feature) in enumerate(zip(placemarks, features)):
image_urls = pm.findall(".//gx:imageUrl", ns)
if not image_urls:
continue
saved = []
for j, img_el in enumerate(image_urls):
data_uri = (img_el.text or "").strip()
match = data_uri_re.match(data_uri)
if not match:
continue
ext = match.group(1)
if ext == "jpeg":
ext = "jpg"
b64_data = match.group(2)
try:
raw = base64.b64decode(b64_data)
filename = f"{i}_{j}.{ext}"
(img_dir / filename).write_bytes(raw)
saved.append(f"/api/images/{dataset_id}/{filename}")
except Exception as e:
logger.warning(f"Erreur décodage image placemark {i} img {j}: {e}")
continue
if saved:
props = json.loads(feature.properties_json)
existing = props.get("_images", [])
props["_images"] = existing + saved
feature.properties_json = json.dumps(props)
session.add(feature)
session.commit()

View File

@@ -0,0 +1,96 @@
import json
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
from sqlmodel import Session, select
from ..database import get_session
from ..models import Dataset, Feature, FeatureVersion
from .images import IMAGES_DIR
router = APIRouter(prefix="/features", tags=["features"])
class FeatureUpdate(BaseModel):
geometry: Optional[dict] = None
properties: Optional[dict] = None
@router.put("/{feature_id}")
def update_feature(
feature_id: int,
data: FeatureUpdate,
session: Session = Depends(get_session),
):
feature = session.get(Feature, feature_id)
if not feature:
raise HTTPException(404, "Feature non trouvée")
before = {
"geometry": json.loads(feature.geometry_json),
"properties": json.loads(feature.properties_json),
}
if data.geometry is not None:
feature.geometry_json = json.dumps(data.geometry)
if data.properties is not None:
feature.properties_json = json.dumps(data.properties)
after = {
"geometry": json.loads(feature.geometry_json),
"properties": json.loads(feature.properties_json),
}
# Sauvegarder la version
version = FeatureVersion(
feature_id=feature_id,
before_json=json.dumps(before),
after_json=json.dumps(after),
)
session.add(version)
session.add(feature)
session.commit()
session.refresh(feature)
# Compter les versions pour ce feature
from sqlmodel import select, func
count = session.exec(
select(func.count()).where(FeatureVersion.feature_id == feature_id)
).one()
return {"id": feature.id, "version": count}
@router.delete("/{feature_id}")
def delete_feature(
feature_id: int,
session: Session = Depends(get_session),
):
feature = session.get(Feature, feature_id)
if not feature:
raise HTTPException(404, "Feature non trouvée")
# Supprimer les versions
versions = session.exec(
select(FeatureVersion).where(FeatureVersion.feature_id == feature_id)
).all()
for v in versions:
session.delete(v)
# Supprimer les fichiers images associés
props = json.loads(feature.properties_json)
for img_url in props.get("_images", []):
if img_url.startswith("/api/images/"):
filename = img_url.split("/")[-1]
filepath = IMAGES_DIR / str(feature.dataset_id) / filename
if filepath.exists():
filepath.unlink()
# Décrémenter le compteur du dataset
dataset = session.get(Dataset, feature.dataset_id)
if dataset:
dataset.feature_count = max(0, dataset.feature_count - 1)
session.add(dataset)
session.delete(feature)
session.commit()
return {"ok": True}

View File

@@ -0,0 +1,141 @@
import json
import base64
import re
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from fastapi.responses import FileResponse
from sqlmodel import Session
from ..database import get_session
from ..models import Feature
from ..config import DATA_DIR
router = APIRouter(prefix="/images", tags=["images"])
IMAGES_DIR = DATA_DIR / "images"
@router.get("/{dataset_id}/{filename}")
def get_image(dataset_id: int, filename: str):
"""Servir une image stockée."""
path = IMAGES_DIR / str(dataset_id) / filename
if not path.exists() or not path.is_file():
raise HTTPException(404, "Image non trouvée")
# Sécurité : vérifier que le chemin résolu est bien dans IMAGES_DIR
if not path.resolve().is_relative_to(IMAGES_DIR.resolve()):
raise HTTPException(403, "Accès interdit")
media_type = "image/jpeg"
if filename.endswith(".png"):
media_type = "image/png"
elif filename.endswith(".webp"):
media_type = "image/webp"
return FileResponse(path, media_type=media_type)
@router.post("/features/{feature_id}")
async def upload_image(
feature_id: int,
file: UploadFile = File(...),
session: Session = Depends(get_session),
):
"""Uploader une nouvelle image pour une feature."""
feature = session.get(Feature, feature_id)
if not feature:
raise HTTPException(404, "Feature non trouvée")
props = json.loads(feature.properties_json)
images = props.get("_images", [])
# Sauvegarder le fichier
img_dir = IMAGES_DIR / str(feature.dataset_id)
img_dir.mkdir(parents=True, exist_ok=True)
ext = Path(file.filename).suffix or ".jpg"
filename = f"{feature_id}_{uuid.uuid4().hex[:8]}{ext}"
filepath = img_dir / filename
content = await file.read()
filepath.write_bytes(content)
# Ajouter l'URL dans les propriétés
url = f"/api/images/{feature.dataset_id}/{filename}"
images.append(url)
props["_images"] = images
feature.properties_json = json.dumps(props)
session.add(feature)
session.commit()
return {"url": url, "images": images}
@router.delete("/features/{feature_id}/{filename}")
def delete_image(
feature_id: int,
filename: str,
session: Session = Depends(get_session),
):
"""Supprimer une image d'une feature."""
feature = session.get(Feature, feature_id)
if not feature:
raise HTTPException(404, "Feature non trouvée")
props = json.loads(feature.properties_json)
images = props.get("_images", [])
# Trouver et supprimer l'URL correspondante
url = f"/api/images/{feature.dataset_id}/{filename}"
if url not in images:
raise HTTPException(404, "Image non trouvée dans cette feature")
images.remove(url)
props["_images"] = images
feature.properties_json = json.dumps(props)
session.add(feature)
session.commit()
# Supprimer le fichier
filepath = IMAGES_DIR / str(feature.dataset_id) / filename
if filepath.exists() and filepath.resolve().is_relative_to(IMAGES_DIR.resolve()):
filepath.unlink()
return {"images": images}
def extract_and_save_images(properties: dict, dataset_id: int, feature_index: int) -> dict:
"""Extraire les images base64 des propriétés et les sauvegarder en fichiers.
Les data URIs dans _images sont remplacées par des URLs /api/images/...
"""
images = properties.get("_images", [])
if not images:
return properties
img_dir = IMAGES_DIR / str(dataset_id)
img_dir.mkdir(parents=True, exist_ok=True)
saved_urls = []
for i, img in enumerate(images):
if isinstance(img, str) and img.startswith("data:image"):
# Extraire le base64
match = re.match(r"data:image/(\w+);base64,(.+)", img, re.DOTALL)
if match:
ext = match.group(1)
if ext == "jpeg":
ext = "jpg"
b64_data = match.group(2)
try:
raw = base64.b64decode(b64_data)
filename = f"{feature_index}_{i}.{ext}"
filepath = img_dir / filename
filepath.write_bytes(raw)
saved_urls.append(f"/api/images/{dataset_id}/{filename}")
except Exception:
continue
elif isinstance(img, str) and img.startswith("/api/images/"):
# Déjà une URL serveur
saved_urls.append(img)
elif isinstance(img, str) and img.startswith("http"):
# URL externe, garder telle quelle
saved_urls.append(img)
properties["_images"] = saved_urls
return properties

View File

@@ -0,0 +1,62 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import Optional
from sqlmodel import Session
from ..database import get_session
from ..models import MapSettings
router = APIRouter(prefix="/settings", tags=["settings"])
class MapSettingsUpdate(BaseModel):
center_lng: Optional[float] = None
center_lat: Optional[float] = None
zoom: Optional[float] = None
base_layer: Optional[str] = None
@router.get("/map")
def get_map_settings(session: Session = Depends(get_session)):
settings = session.get(MapSettings, 1)
if not settings:
return {
"center_lng": 2.35,
"center_lat": 48.85,
"zoom": 5.0,
"base_layer": "vector",
}
return {
"center_lng": settings.center_lng,
"center_lat": settings.center_lat,
"zoom": settings.zoom,
"base_layer": settings.base_layer,
}
@router.put("/map")
def save_map_settings(
data: MapSettingsUpdate,
session: Session = Depends(get_session),
):
settings = session.get(MapSettings, 1)
if not settings:
settings = MapSettings(id=1)
if data.center_lng is not None:
settings.center_lng = data.center_lng
if data.center_lat is not None:
settings.center_lat = data.center_lat
if data.zoom is not None:
settings.zoom = data.zoom
if data.base_layer is not None:
settings.base_layer = data.base_layer
session.add(settings)
session.commit()
session.refresh(settings)
return {
"center_lng": settings.center_lng,
"center_lat": settings.center_lat,
"zoom": settings.zoom,
"base_layer": settings.base_layer,
}

View File

@@ -0,0 +1 @@
{"type": "Point"}

View File

@@ -0,0 +1 @@
{"type": "Point"}

View File

@@ -0,0 +1 @@
{"type": "Point"}

View File

@@ -0,0 +1 @@
{"type": "Point"}

View File

@@ -0,0 +1 @@
{"type": "Point"}

View File

@@ -0,0 +1 @@
{"type": "Point"}

View File

@@ -0,0 +1 @@
{"type": "Point"}

View File

@@ -0,0 +1 @@
{"type": "Point"}

View File

@@ -0,0 +1 @@
{"type": "Point"}

View File

@@ -0,0 +1 @@
{"type": "Point"}

View File

@@ -0,0 +1 @@
{"type": "Point"}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

View File

@@ -0,0 +1 @@
{"type": "FeatureCollection", "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [2.35, 48.85]}, "properties": {"name": "Paris", "description": "Capitale de la France"}}, {"type": "Feature", "geometry": {"type": "Point", "coordinates": [5.37, 43.3]}, "properties": {"name": "Marseille"}}]}

0
backend/data/webcarto.db Normal file
View File

7
backend/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
fastapi>=0.115
uvicorn[standard]>=0.34
sqlmodel>=0.0.22
alembic>=1.15
pydantic>=2.10
python-multipart>=0.0.17
aiofiles>=24.1

View File

Binary file not shown.

188
backend/tests/test_api.py Normal file
View File

@@ -0,0 +1,188 @@
import json
import pytest
from fastapi.testclient import TestClient
from sqlmodel import SQLModel, create_engine, Session
from sqlalchemy.pool import StaticPool
from app.main import app
from app.database import get_session
from app.models import Dataset, Feature, FeatureVersion, MapSettings # noqa: F401
# SQLite in-memory avec StaticPool pour garder la même connexion
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
def override_get_session():
with Session(engine) as session:
yield session
app.dependency_overrides[get_session] = override_get_session
client = TestClient(app)
SAMPLE_GEOJSON = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [2.35, 48.85]},
"properties": {"name": "Paris", "description": "Capitale de la France"},
},
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [5.37, 43.30]},
"properties": {"name": "Marseille"},
},
],
}
@pytest.fixture(autouse=True)
def setup_db():
SQLModel.metadata.create_all(engine)
yield
SQLModel.metadata.drop_all(engine)
def test_health():
r = client.get("/api/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
def test_list_datasets_empty():
r = client.get("/api/datasets")
assert r.status_code == 200
assert r.json() == []
def test_import_geojson():
geojson_str = json.dumps(SAMPLE_GEOJSON)
r = client.post(
"/api/datasets/import",
data={"geojson": geojson_str},
files={"file": ("test.geojson", geojson_str.encode(), "application/json")},
)
assert r.status_code == 200
data = r.json()
assert data["name"] == "test"
assert data["feature_count"] == 2
assert data["id"] is not None
def test_import_invalid_geojson():
r = client.post(
"/api/datasets/import",
data={"geojson": '{"type": "Point"}'},
files={"file": ("bad.geojson", b'{"type": "Point"}', "application/json")},
)
assert r.status_code == 400
def test_get_dataset():
geojson_str = json.dumps(SAMPLE_GEOJSON)
r = client.post(
"/api/datasets/import",
data={"geojson": geojson_str},
files={"file": ("villes.geojson", geojson_str.encode(), "application/json")},
)
ds_id = r.json()["id"]
r = client.get(f"/api/datasets/{ds_id}")
assert r.status_code == 200
data = r.json()
assert data["name"] == "villes"
assert len(data["features"]) == 2
assert data["features"][0]["properties"]["name"] == "Paris"
def test_update_feature():
geojson_str = json.dumps(SAMPLE_GEOJSON)
r = client.post(
"/api/datasets/import",
data={"geojson": geojson_str},
files={"file": ("test.geojson", geojson_str.encode(), "application/json")},
)
ds_id = r.json()["id"]
r = client.get(f"/api/datasets/{ds_id}")
feature_id = r.json()["features"][0]["id"]
r = client.put(
f"/api/features/{feature_id}",
json={"properties": {"name": "Paris modifié", "description": "Mis à jour"}},
)
assert r.status_code == 200
assert r.json()["version"] == 1
r = client.get(f"/api/datasets/{ds_id}")
f = next(f for f in r.json()["features"] if f["id"] == feature_id)
assert f["properties"]["name"] == "Paris modifié"
def test_export_dataset():
geojson_str = json.dumps(SAMPLE_GEOJSON)
r = client.post(
"/api/datasets/import",
data={"geojson": geojson_str},
files={"file": ("test.geojson", geojson_str.encode(), "application/json")},
)
ds_id = r.json()["id"]
r = client.post(f"/api/datasets/{ds_id}/export?format=geojson")
assert r.status_code == 200
fc = r.json()
assert fc["type"] == "FeatureCollection"
assert len(fc["features"]) == 2
def test_dataset_not_found():
r = client.get("/api/datasets/9999")
assert r.status_code == 404
def test_feature_not_found():
r = client.put("/api/features/9999", json={"properties": {"name": "x"}})
assert r.status_code == 404
def test_delete_feature():
geojson_str = json.dumps(SAMPLE_GEOJSON)
r = client.post(
"/api/datasets/import",
data={"geojson": geojson_str},
files={"file": ("test.geojson", geojson_str.encode(), "application/json")},
)
ds_id = r.json()["id"]
r = client.get(f"/api/datasets/{ds_id}")
feature_id = r.json()["features"][0]["id"]
r = client.delete(f"/api/features/{feature_id}")
assert r.status_code == 200
assert r.json()["ok"] is True
# Vérifier que la feature n'existe plus
r = client.get(f"/api/datasets/{ds_id}")
assert len(r.json()["features"]) == 1
assert r.json()["feature_count"] == 1
def test_delete_dataset():
geojson_str = json.dumps(SAMPLE_GEOJSON)
r = client.post(
"/api/datasets/import",
data={"geojson": geojson_str},
files={"file": ("test.geojson", geojson_str.encode(), "application/json")},
)
ds_id = r.json()["id"]
r = client.delete(f"/api/datasets/{ds_id}")
assert r.status_code == 200
assert r.json()["ok"] is True
# Vérifier que le dataset n'existe plus
r = client.get(f"/api/datasets/{ds_id}")
assert r.status_code == 404

226
consigne.md Normal file
View File

@@ -0,0 +1,226 @@
Prompt Claude Code (WebApp carto KML/GeoJSON + fonds type Google Earth, Docker)
Tu es Claude Code. Objectif : développer une webapp self-hosted moderne (UI gruvbox dark vintage) installable en Docker, qui affiche une carte avec fonds satellite / hybride / vecteur, permet dimporter des fichiers locaux KML et GeoJSON, puis déditer/ déplacer des points et modifier leurs attributs (texte/HTML), en affichant aussi les images intégrées (ex : dans les descriptions KML ou champs GeoJSON).
Contraintes & exigences
Projet “production-ready” minimal, mais extensible.
Import local via navigateur (drag&drop + bouton).
Aucune dépendance à Google Earth desktop : tout doit marcher via navigateur.
Style : gruvbox dark vintage (thème cohérent, contrastes, lisibilité).
Docker : un docker-compose.yml pour lancer lensemble.
Stockage : persistance des datasets importés + historique des modifications (versioning simple).
Sécurité : accès LAN seulement (pas dauth pour v1), mais structure prête pour ajout futur.
Performance : datasets raisonnables (ex: 5k50k points). Prévoir clustering/virtualisation si besoin.
Architecture attendue
Frontend : SPA moderne (TypeScript), composants UI, panneau latéral, import, édition.
Backend : API REST (Python FastAPI) pour persister les couches/datasets, servir les fichiers, gérer versions.
Stockage : SQLite + stockage fichiers sur volume (KML/GeoJSON originaux + export).
Export : permettre dexporter la couche éditée en GeoJSON (v1) + KML (v2).
Tests : au moins tests unitaires backend (API import/validation) + tests frontend basiques (import parsing).
Fonctionnalités détaillées (MVP)
Carte & fonds
Afficher une carte plein écran.
Proposer 3 fonds : “Satellite”, “Hybride”, “Vecteur”.
Utiliser un fournisseur de tuiles compatible (pas besoin dAPI key au début si possible), mais prévoir la configuration via .env (URL templates, attribution).
Afficher lattribution correctement.
Import fichiers
Import KML local (fichier .kml / .kmz si simple).
Import GeoJSON local.
À limport : parser, valider, normaliser (projection WGS84).
Convertir en “couches” internes : points / lignes / polygones.
Conserver le “raw” original pour re-téléchargement.
Edition
Sélection sur la carte (clic) + liste des features dans un panneau.
Déplacement des points (drag) + undo/redo (au moins undo 1 niveau).
Edition des propriétés : name, description, champs libres.
Affichage du contenu rich (HTML safe) dans un viewer.
Si images :
Cas 1 : description contient une URL http(s) -> afficher image.
Cas 2 : KML contient des références dimages (ou CDATA) -> extraire URLs si présentes.
Ne pas tenter dOCR, ne pas embedder des binaires dans v1.
UI/UX
Layout :
Header : nom projet + boutons Import / Export / Save / Settings.
Volet gauche : liste couches + recherche + filtres.
Volet droit : panneau “Propriétés” de la feature sélectionnée (avec preview images).
Thème gruvbox dark vintage : palette, tokens CSS, typographie lisible.
Toasts + barre de statut (import, parsing, sauvegarde).
Backend
Endpoints :
POST /api/datasets/import (reçoit metadata + contenu si small, ou upload multipart).
GET /api/datasets
GET /api/datasets/{id}
PUT /api/features/{id} (update geometry + properties)
POST /api/datasets/{id}/export?format=geojson
Validation Pydantic : structure GeoJSON, taille max, champs autorisés.
Versioning simple : table feature_versions (timestamp, before/after).
Choix techniques (à appliquer)
Carte : privilégier MapLibre GL JS (vector) + possibilité raster, OU Leaflet si plus simple.
KML parsing : parser côté frontend (lib JS) puis envoyer au backend en GeoJSON normalisé.
GeoJSON : édition via libs de dessin (MapLibre + draw) ou (Leaflet + Geoman).
State management : simple (Zustand) ou équivalent.
UI : TailwindCSS + composants (shadcn-like ou headless) pour rapidité.
Livrables attendus par toi (Claude Code)
Arborescence projet complète.
docker-compose.yml + Dockerfiles frontend/backend.
Backend FastAPI prêt : routes, modèles, migrations (alembic) si nécessaire.
Frontend prêt : carte, import, liste, sélection, déplacement point, édition propriétés.
README clair : installation, variables .env, limites connues, roadmap.
Dataset dexemple (1 KML, 1 GeoJSON) dans /samples/.
Roadmap (après MVP)
Support KMZ, styles KML (icônes, couleurs).
Edition lignes/polygones.
Auth (OIDC / reverse proxy).
Multi-utilisateurs, permissions.
Import de grands datasets (streaming, worker).
Offline tiles / serveur tuiles interne.
Commence par proposer la structure du repo + fichiers Docker + une première version fonctionnelle MVP. Implémente ensuite par incréments : carte -> import GeoJSON -> édition point -> import KML -> persistance -> export.
Brainstorming outils (stack Docker) pour ce projet
Carto / rendu carte (frontend)
MapLibre GL JS : rendu vectoriel moderne, performant, style JSON, compatible “vecteur” + raster.
Leaflet : très simple, énorme écosystème, mais moins “GPU”/vecteur natif.
Mapbox Draw / MapLibre Draw ou Leaflet-Geoman : outils dédition/dessin (drag points, edit).
Import / conversion
togeojson (KML -> GeoJSON, JS) : classique, efficace.
@tmcw/togeojson variantes / ou libs KML parser modernes.
geojson-validation ou validation custom côté backend.
Pour KMZ plus tard : unzip + parsing KML.
UI moderne
Vite + React + TypeScript
TailwindCSS + tokens de thème (gruvbox).
Composants : Radix UI (headless) ou shadcn/ui-like.
State : Zustand (simple, efficace).
Backend & persistance
FastAPI + Pydantic
SQLite (MVP) via SQLModel ou SQLAlchemy
Alembic (migrations si tu veux solide)
Stockage fichiers : volume Docker (/data)
Export & traitement géo
Shapely / pyproj côté backend si besoin dopérations (simplification, reprojection), mais v1 peut rester “pass-through” en WGS84.
Sinon 100% côté frontend (GeoJSON only) et backend persiste.
Dev / qualité
pytest (backend)
ruff + black (backend)
eslint + prettier (frontend)
OpenAPI auto (FastAPI) pour tester vite.
Docker / déploiement
2 services :
backend (FastAPI + volume /data)
frontend (build Vite puis Nginx)
Option 3 : un Caddy ou Traefik plus tard (pas requis MVP).
Variables .env : URLs des fonds de carte, limites upload, chemins data.
Fonds “satellite / hybride”
Attention : “Google Earth” au sens strict nest pas un fournisseur de tuiles libre. Pour MVP :
Utiliser des raster tiles (satellite) et vector tiles (OSM vector) via fournisseurs compatibles, configurables.
Prévoir abstraction “BaseLayerProvider” configurable (URL template + attribution + type raster/vector).
“Hybride” = satellite + labels (deux couches superposées).

BIN
data/images/1/18_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 KiB

BIN
data/images/1/19_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

BIN
data/images/1/20_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
data/images/1/21_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
data/images/1/22_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
data/images/1/23_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1021 KiB

BIN
data/images/1/24_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
data/images/1/25_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
data/images/1/26_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
data/images/1/27_0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 KiB

Some files were not shown because too many files have changed in this diff Show More