Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions chatmap-api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
10 changes: 8 additions & 2 deletions chatmap-ui/src/components/DownloadButton/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<sl-button
disabled={disabled}
className={className}
variant="default"
outline
size="small"
Expand Down
2 changes: 1 addition & 1 deletion chatmap-ui/src/components/SaveDialog/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export default function SaveDialog({
<SlTextarea
name="description"
label={intl.formatMessage({id: "app.save.description", defaultMessage: "What is this map about?"})}
placeholder={intl.formatMessage({id: "app.save.descriptionPlaceholder", defaultMessage: "You can use markdown"})}
placeholder={intl.formatMessage({id: "app.save.descriptionPlaceholder", defaultMessage: ""})}
/>

<SlButton type="submit" variant="primary" className="dialog__btn dark-btn">
Expand Down
99 changes: 62 additions & 37 deletions chatmap-ui/src/pages/mapView/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -166,42 +169,9 @@ function MapView() {
id={mapData.id}
/>
</>}
{ mapData.is_live && mapData.owner && <>
<sl-dropdown>
<SlButton size="large" variant="text" slot="trigger">
<SlIcon name="three-dots-vertical" slot="prefix" />
</SlButton>
<div className="map__options">
<h3>Live</h3>
<p>
<FormattedMessage id="app.map.linkedMap" defaultMessage="This map is linked to a device" />.
</p>
<SlButton variant="danger" onClick={() => { setConfirmDialogData("unlink-device"); setConfirmDialogOpen(true);} }>
<SlIcon name="dash-circle-fill" slot="prefix" />
<FormattedMessage id="app.map.unlinkDevice" defaultMessage="Unlink device" />
</SlButton>
<p><FormattedMessage id="app.map.unlinkOption2" defaultMessage="Or, if you want to start a new Live map" />:</p>
<SlButton variant="default" onClick={() => { setConfirmDialogData("unlink-map"); setConfirmDialogOpen(true); }}>
<SlIcon name="dash-circle-fill" slot="prefix" />
<FormattedMessage id="app.map.unlinkThisMap" defaultMessage="Unlink this map" />
</SlButton>
</div>
</sl-dropdown>
</>}
{ dataAvailable && mapData.owner && !mapData.is_live && <>

{ !hasNewData &&
<FileUpload
onDataFileLoad={handleDataFile}
onFilesLoad={handleFiles}
>
<SlButton size="small">
<SlIcon name="file-earmark-plus-fill" slot="prefix" />
<FormattedMessage id="app.map.addNew" defaultMessage="Add" />
</SlButton>
</FileUpload> }

{ hasNewData && <>

{/* Update map with new data */}
{ hasNewData && newMapData.features.length > 0 && <>
<UpdateButton
mapData={mapData}
data={data}
Expand All @@ -212,7 +182,62 @@ function MapView() {
setLoading={setBeingSaved}
/>
</> }
</>}

<sl-dropdown>
<SlButton size="large" variant="text" slot="trigger">
<SlIcon name="three-dots-vertical" slot="prefix" />
</SlButton>
<div className="map__options">

<div className="map__options_data">
<h3>Data</h3>

{ dataAvailable && mapData.owner && !mapData.is_live && <>
{/* Add new data to the map */}
{ !hasNewData &&
<FileUpload
onDataFileLoad={handleDataFile}
onFilesLoad={handleFiles}
>
<SlButton size="small" className="map__options_button">
<SlIcon name="file-earmark-plus-fill" slot="prefix" />
<FormattedMessage id="app.map.addNew" defaultMessage="Add" />
</SlButton>
</FileUpload> }
</>}

<DownloadButton disabled={hasNewData && newMapData.features.length > 0} url={`${config.API_URL}/export/${mapData.id}`} className="map__options_button" />
<SlButton
target="_blank"
href={`${config.API_URL}/map/${mapData.id}`}
className="map__options_button" size="small"
>
API link
</SlButton>

</div>

{/* Live options */}
{ mapData.is_live && mapData.owner && <>
<div className="map__options_live">
<h3>Live</h3>
<p>
<FormattedMessage id="app.map.linkedMap" defaultMessage="This map is linked to a device" />.
</p>
<SlButton className="map__options_button" size="small" variant="danger" onClick={() => { setConfirmDialogData("unlink-device"); setConfirmDialogOpen(true);} }>
<SlIcon name="dash-circle-fill" slot="prefix" />
<FormattedMessage id="app.map.unlinkDevice" defaultMessage="Unlink device" />
</SlButton>
<p><FormattedMessage id="app.map.unlinkOption2" defaultMessage="Or, if you want to start a new Live map" />:</p>
<SlButton className="map__options_button" size="small" variant="default" onClick={() => { setConfirmDialogData("unlink-map"); setConfirmDialogOpen(true); }}>
<SlIcon name="dash-circle-fill" slot="prefix" />
<FormattedMessage id="app.map.unlinkThisMap" defaultMessage="Unlink this map" />
</SlButton>
</div>
</>}
</div>
</sl-dropdown>

</Header>

{dataAvailable &&
Expand Down
19 changes: 18 additions & 1 deletion chatmap-ui/src/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading