diff --git a/chatmap-api/main.py b/chatmap-api/main.py index aeedf8e..d85a303 100644 --- a/chatmap-api/main.py +++ b/chatmap-api/main.py @@ -6,9 +6,12 @@ """ import os +import io import httpx import logging import asyncio +import zipfile +import json from pathlib import Path from uuid import uuid4 from collections import defaultdict @@ -38,6 +41,7 @@ from geoalchemy2.shape import to_shape from hotosm_auth_fastapi import setup_auth, CurrentUser, CurrentUserOptional + # Logs logging.basicConfig( format='[API] %(levelname)s: %(message)s', @@ -112,7 +116,6 @@ async def status( Dict[str, str]: Status of the session. """ async with httpx.AsyncClient() as client: - print(user.id) response = await client.get(f'{SERVER_URL}/status?session={user.id}') if response.status_code != 200: raise HTTPException(status_code=502, detail="Failed to get session") @@ -653,7 +656,6 @@ async def update_point_tags( user: CurrentUser, db: Session = Depends(get_db_session), ): - print(tags) point_obj: Point = db.get(Point, point_id) if point_obj: map_obj = point_obj.map @@ -769,6 +771,72 @@ async def me(user: CurrentUser): 'username': user.username, } +# Export +@api_router.get("/export/{map_id}", response_model=None) +async def get_public_map( + map_id: str, + request: Request, + user: CurrentUserOptional, + db: Session = Depends(get_db_session), +): + """ + Export map for download (Zip) for a given map ID. + + Args: + map_id (str): Unique identifier of the map. + request (Request): FastAPI request object. + db (Session): Database session. + + Returns: + StreamingResponse + """ + + # Get map + map_obj: Map = db.get(Map, map_id) + owner = (user and map_obj.owner_id == user.id) or False + if map_obj and (map_obj.sharing == SharePermission.PUBLIC or owner): + map = map_response(db, map_obj, owner) + memory_file = io.BytesIO() + + with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf: + async with httpx.AsyncClient() as client: + # Get map files + for feature in map['features']: + fileUrl = feature['properties']['file'] + if not fileUrl: + continue + try: + response = await client.get(fileUrl) + response.raise_for_status() # Raise for 4xx/5xx + content = response.content + # Get filename + filename = fileUrl.replace("media?filename=", "/") + filename = filename[filename.rfind("/") + 1:] + # Update file properties + feature['properties']['file_url'] = feature['properties']['file'] + feature['properties']['file'] = filename + # Add file to zip + zf.writestr(filename, content) + except httpx.HTTPError as e: + logger.error(f"Failed to download: {str(e)}") + # Replace 'id' by '_chatmapId' + map['_chatmapId'] = map['id'] + del map['id'] + zf.writestr(f"chatmap_{map_id}.geojson", json.dumps(map, default=str)) + + memory_file.seek(0) + return StreamingResponse( + memory_file, + media_type="application/zip", + headers={"Content-Disposition": f"attachment; filename=chatmap_{map_id}.zip"} + ) + else: + # Map is not public – reject the request + raise HTTPException( + status_code=401, + detail="Unauthorized: the requested map is not publicly shared." + ) + # Include API Router app.include_router(api_router) diff --git a/chatmap-ui/src/components/DownloadButton/index.jsx b/chatmap-ui/src/components/DownloadButton/index.jsx index 3dc33fa..2729462 100644 --- a/chatmap-ui/src/components/DownloadButton/index.jsx +++ b/chatmap-ui/src/components/DownloadButton/index.jsx @@ -50,14 +50,20 @@ function createAndDownloadZip(data, dataFiles) { }); } -function DownloadButton({ data, dataFiles }) { +function DownloadButton({ data, dataFiles, url, className, disabled }) { const handleClick = () => { - createAndDownloadZip(data, dataFiles); + if (url) { + window.open(url); + } else { + createAndDownloadZip(data, dataFiles); + } }; return ( diff --git a/chatmap-ui/src/pages/mapView/index.jsx b/chatmap-ui/src/pages/mapView/index.jsx index c46682a..baefc51 100644 --- a/chatmap-ui/src/pages/mapView/index.jsx +++ b/chatmap-ui/src/pages/mapView/index.jsx @@ -22,8 +22,11 @@ import EditMapDialog from '../../components/EditMapDialog/index.jsx'; import InfoMapDialog from '../../components/InfoMapDialog/index.jsx'; import Progress from "../../components/Progress/index.jsx"; import ConfirmDialog from "../../components/ConfirmDialog/index.jsx"; +import DownloadButton from '../../components/DownloadButton'; +import { useConfigContext } from "../../context/ConfigContext.jsx" function MapView() { + const { config } = useConfigContext(); const [editMapDialogOpen, setEditMapDialogOpen] = useState(false); const [infoMapDialogOpen, setInfoMapDialogOpen] = useState(false); const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); @@ -166,42 +169,9 @@ function MapView() { id={mapData.id} /> } - { mapData.is_live && mapData.owner && <> - - - - -
-

Live

-

- . -

- { setConfirmDialogData("unlink-device"); setConfirmDialogOpen(true);} }> - - - -

:

- { setConfirmDialogData("unlink-map"); setConfirmDialogOpen(true); }}> - - - -
-
- } - { dataAvailable && mapData.owner && !mapData.is_live && <> - - { !hasNewData && - - - - - - } - - { hasNewData && <> + + {/* Update map with new data */} + { hasNewData && newMapData.features.length > 0 && <> } - } + + + + + +
+ +
+

Data

+ + { dataAvailable && mapData.owner && !mapData.is_live && <> + {/* Add new data to the map */} + { !hasNewData && + + + + + + } + } + + 0} url={`${config.API_URL}/export/${mapData.id}`} className="map__options_button" /> + + API link + + +
+ + {/* Live options */} + { mapData.is_live && mapData.owner && <> +
+

Live

+

+ . +

+ { setConfirmDialogData("unlink-device"); setConfirmDialogOpen(true);} }> + + + +

:

+ { setConfirmDialogData("unlink-map"); setConfirmDialogOpen(true); }}> + + + +
+ } +
+
+ {dataAvailable && diff --git a/chatmap-ui/src/styles/main.css b/chatmap-ui/src/styles/main.css index 762a54f..9f2e2a7 100644 --- a/chatmap-ui/src/styles/main.css +++ b/chatmap-ui/src/styles/main.css @@ -383,7 +383,24 @@ https://github.com/KarimMokhtar/react-drag-drop-files/issues/121 */ .map__options { background-color: var(--hot-color-neutral-0); - padding: var(--hot-spacing-large) + padding: var(--hot-spacing-x-small) var(--hot-spacing-large); + box-shadow: 0 0 10px rgba(0,0,0,.3); + border-radius: var(--hot-border-radius-medium); + margin-right: var(--hot-spacing-small); +} + +.map__options_live { + border-top: 1px solid var(--hot-color-gray-200); + padding-bottom: var(--hot-spacing-small); +} + +.map__options p { + font-size: var(--hot-font-size-small); +} + +.map__options_button { + width: 100%; + margin-bottom: var(--hot-spacing-small); } /* Error boundary */