Skip to content

Commit 563f190

Browse files
committed
Merge branch 'development' of github.com:hotosm/fmtm into fix/offline-bugs
2 parents 7f4f413 + 31a446c commit 563f190

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+597
-201
lines changed

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,11 @@ OTEL_AUTH_TOKEN=${OTEL_AUTH_TOKEN}
6161
# Monitoring (Sentry)
6262
SENTRY_DSN=${SENTRY_DSN}
6363

64+
## SMTP CONFIG ##
65+
SMTP_TLS=${SMTP_TLS:-True}
66+
SMTP_SSL=${SMTP_SSL:-False}
67+
SMTP_PORT=${SMTP_PORT:-587}
68+
SMTP_HOST=${SMTP_HOST}
69+
SMTP_USER=${SMTP_USER}
70+
SMTP_PASSWORD=${SMTP_PASSWORD}
71+
EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL}

compose.yaml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ services:
214214
- /app/.svelte-kit/
215215
# - ../ui:/app/node_modules/@hotosm/ui:ro
216216
environment:
217+
- TEST_PWA=${TEST_PWA:-false}
217218
- VITE_API_URL=http://api.${FMTM_DOMAIN}:${FMTM_DEV_PORT:-7050}
218219
- VITE_SYNC_URL=http://sync.${FMTM_DOMAIN}:${FMTM_DEV_PORT:-7050}
219220
- VITE_S3_URL=http://s3.${FMTM_DOMAIN}:${FMTM_DEV_PORT:-7050}
@@ -222,6 +223,8 @@ services:
222223
networks:
223224
- fmtm-net
224225
restart: "unless-stopped"
226+
command: >
227+
sh -c "if [ \"$TEST_PWA\" = \"true\" ]; then pnpm run build && pnpm run preview; else pnpm run dev; fi"
225228
226229
central:
227230
profiles: ["", "central"]
@@ -343,7 +346,7 @@ services:
343346
fmtm-db:
344347
# Temp workaround until https://github.com/postgis/docker-postgis/issues/216
345348
image: "ghcr.io/hotosm/postgis:${POSTGIS_TAG:-14-3.5-alpine}"
346-
command: -c 'max_connections=300' -c 'wal_level=logical'
349+
command: -c 'wal_level=logical'
347350
volumes:
348351
- fmtm_db_data:/var/lib/postgresql/data/
349352
environment:
@@ -363,7 +366,7 @@ services:
363366
retries: 3
364367

365368
electric:
366-
image: "electricsql/electric:${ELECTRIC_TAG:-1.0.11}"
369+
image: "electricsql/electric:${ELECTRIC_TAG:-1.0.12}"
367370
depends_on:
368371
proxy:
369372
condition: service_started
@@ -385,6 +388,7 @@ services:
385388
central-db:
386389
profiles: ["", "central"]
387390
image: "ghcr.io/hotosm/postgis:${POSTGIS_TAG:-14-3.5-alpine}"
391+
command: -c 'max_connections=300' -c 'wal_level=logical'
388392
volumes:
389393
- central_db_data:/var/lib/postgresql/data/
390394
environment:

deploy/compose.development.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ services:
309309
retries: 3
310310

311311
electric:
312-
image: "electricsql/electric:${ELECTRIC_TAG:-1.0.11}"
312+
image: "electricsql/electric:${ELECTRIC_TAG:-1.0.12}"
313313
depends_on:
314314
proxy:
315315
condition: service_started
@@ -331,6 +331,7 @@ services:
331331

332332
central-db:
333333
image: "ghcr.io/hotosm/postgis:${POSTGIS_TAG:-14-3.5-alpine}"
334+
command: -c 'max_connections=300'
334335
volumes:
335336
- central_db_data:/var/lib/postgresql/data/
336337
environment:

deploy/compose.sub.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ services:
309309
retries: 3
310310

311311
electric:
312-
image: "electricsql/electric:${ELECTRIC_TAG:-1.0.11}"
312+
image: "electricsql/electric:${ELECTRIC_TAG:-1.0.12}"
313313
depends_on:
314314
proxy:
315315
condition: service_started
@@ -331,6 +331,7 @@ services:
331331

332332
central-db:
333333
image: "ghcr.io/hotosm/postgis:${POSTGIS_TAG:-14-3.5-alpine}"
334+
command: -c 'max_connections=300'
334335
volumes:
335336
- central_db_data:/var/lib/postgresql/data/
336337
environment:

docs/manuals/mapping.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,30 @@ Sometimes the feature does not exist on the map yet!
220220
In a future release, this process will be handled seamlessly
221221
without user interaction.
222222

223+
## Working Offline
224+
225+
It is possible to work entirely offline, once FieldTM has been loaded
226+
while internet is available.
227+
228+
To achieve this:
229+
230+
- Visit [https://mapper.fmtm.hotosm.org](https://mapper.fmtm.hotosm.org)
231+
once from the phone you wish to map with.
232+
- This will cache the 12 most recent projects on your device.
233+
- To cache a different set of projects, use the search or
234+
next/previous page functionality.
235+
- For best results, 'Install' the webpage on your device, creating
236+
a shortcut on your home screen.
237+
- Click on the project you wish to map offline.
238+
- The project details will be stored entirely offline.
239+
- You may also go to the 'Offline' tab, to download additional
240+
offline data such as base map imagery.
241+
- Upon opening the installed app, you should be able to browse
242+
the cached versions of your projects (note they will not update
243+
while offline).
244+
- Submissions can be made while offline & they will since when
245+
connectivity is restored.
246+
223247
## Upcoming improvements
224248

225249
Refer the milestone: <https://github.com/hotosm/fmtm/milestone/49>

src/Dockerfile.ui.debug

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ RUN npm install -g [email protected]
1818
# FIXME delete this line after fixed upstream
1919
RUN corepack enable && corepack install
2020
RUN pnpm install
21-
ENTRYPOINT ["pnpm", "run", "dev"]
21+
CMD ["pnpm", "run", "dev"]
2222

2323

2424
# Test code for initialising PGLite db and creating tar dump

src/backend/app/config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,22 @@ def monitoring_config(self) -> Optional[OpenObserveSettings | SentrySettings]:
367367
return OpenObserveSettings()
368368
return None
369369

370+
# SMTP Configurations
371+
SMTP_TLS: bool = True
372+
SMTP_SSL: bool = False
373+
SMTP_PORT: int = 587
374+
SMTP_HOST: Optional[str] = None
375+
SMTP_USER: Optional[str] = None
376+
SMTP_PASSWORD: Optional[str] = None
377+
EMAILS_FROM_EMAIL: Optional[str] = None
378+
EMAILS_FROM_NAME: Optional[str] = "Field-TM"
379+
380+
@computed_field
381+
@property
382+
def emails_enabled(self) -> bool:
383+
"""Check if email settings are configured."""
384+
return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)
385+
370386

371387
@lru_cache
372388
def get_settings():

src/backend/app/helpers/helper_routes.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@
1919

2020
import csv
2121
import json
22+
from email.message import EmailMessage
2223
from io import BytesIO, StringIO
2324
from pathlib import Path
2425
from textwrap import dedent
2526
from typing import Annotated
2627
from uuid import uuid4
2728

29+
import aiosmtplib
2830
import requests
2931
from fastapi import (
3032
APIRouter,
@@ -333,3 +335,32 @@ async def send_test_osm_message(
333335
return HTTPException(status_code=HTTPStatus.CONFLICT, detail=msg)
334336

335337
return Response(status_code=HTTPStatus.OK)
338+
339+
340+
@router.post("/send-test-email")
341+
async def send_test_email():
342+
"""Sends a test email using real SMTP settings."""
343+
if not settings.emails_enabled:
344+
raise HTTPException(
345+
status_code=HTTPStatus.NOT_IMPLEMENTED,
346+
detail="An SMTP server has not been configured.",
347+
)
348+
message = EmailMessage()
349+
message["Subject"] = "Test email from Field-TM"
350+
message.set_content("This is a test email sent from Field-TM.")
351+
352+
log.info("Sending test email to recipients")
353+
await aiosmtplib.send(
354+
message,
355+
sender=settings.SMTP_USER,
356+
# NOTE this is a test email, so use your own email address to receive it
357+
recipients=[], # List of recipient email addresses
358+
hostname=settings.SMTP_HOST,
359+
port=settings.SMTP_PORT,
360+
username=settings.SMTP_USER,
361+
password=settings.SMTP_PASSWORD,
362+
)
363+
364+
return Response(
365+
status_code=HTTPStatus.OK,
366+
)

src/backend/app/users/user_crud.py

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@
1919

2020
import os
2121
from datetime import datetime, timedelta, timezone
22+
from email.message import EmailMessage
2223
from textwrap import dedent
2324
from typing import Literal, Optional
2425

26+
import aiosmtplib
27+
import markdown
2528
from fastapi import Request
2629
from fastapi.exceptions import HTTPException
2730
from loguru import logger as log
@@ -217,41 +220,78 @@ async def send_warning_email_or_osm(
217220
log.info(f"Sent warning to {username}: {days_remaining} days remaining.")
218221

219222

220-
async def send_invitation_osm_message(
223+
async def send_mail(
224+
user_email: str,
225+
title: str,
226+
message_content: str,
227+
):
228+
"""Sends an email."""
229+
if not settings.emails_enabled:
230+
log.info("An SMTP server has not been configured.")
231+
return
232+
message = EmailMessage()
233+
message["Subject"] = title
234+
message.set_content(message_content)
235+
html_content = markdown.markdown(message_content)
236+
message.add_alternative(html_content, subtype="html")
237+
238+
await aiosmtplib.send(
239+
message,
240+
sender=settings.SMTP_USER,
241+
recipients=[user_email],
242+
hostname=settings.SMTP_HOST,
243+
port=settings.SMTP_PORT,
244+
username=settings.SMTP_USER,
245+
password=settings.SMTP_PASSWORD,
246+
)
247+
248+
249+
async def send_invitation_message(
221250
request: Request,
222251
project: DbProject,
223252
invitee_username: str,
224253
osm_auth: Auth,
225254
invite_url: str,
255+
user_email: str,
256+
signin_type: str,
226257
):
227258
"""Send an invitation message to a user to join a project."""
228-
log.info(f"Sending invitation message to osm user ({invitee_username}).")
229-
230-
osm_token = get_osm_token(request, osm_auth)
231-
232259
project_url = f"{settings.FMTM_DOMAIN}/project/{project.id}"
233260
if not project_url.startswith("http"):
234261
project_url = f"https://{project_url}"
235262

263+
title = f"You have been invited to join the project {project.name}"
236264
message_content = dedent(f"""
237265
You have been invited to join the project **{project.name}**.
238266
239267
To accept the invitation, please click the link below:
240268
[Accept Invitation]({invite_url})
241269
242-
You may use this link after accepting the invitation to view the project:
270+
You may use this link after accepting the invitation to view the project if you
271+
have access:
243272
[Project]({project_url})
244273
245274
Thank you for being a part of our platform!
246275
""")
247276

248-
send_osm_message(
249-
osm_token=osm_token,
250-
osm_username=invitee_username,
251-
title=f"You have been invited to join the project {project.name}",
252-
body=message_content,
253-
)
254-
log.info(f"Invitation message sent to osm user ({invitee_username}).")
277+
if signin_type == "osm":
278+
osm_token = get_osm_token(request, osm_auth)
279+
280+
send_osm_message(
281+
osm_token=osm_token,
282+
osm_username=invitee_username,
283+
title=title,
284+
body=message_content,
285+
)
286+
log.info(f"Invitation message sent to osm user ({invitee_username}).")
287+
288+
elif signin_type == "google":
289+
await send_mail(
290+
user_email=user_email,
291+
title=title,
292+
message_content=message_content,
293+
)
294+
log.info(f"Invitation message sent to email user ({user_email}).")
255295

256296

257297
async def get_paginated_users(

src/backend/app/users/user_routes.py

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
from app.users.user_crud import (
5252
get_paginated_users,
5353
process_inactive_users,
54-
send_invitation_osm_message,
54+
send_invitation_message,
5555
)
5656
from app.users.user_deps import get_user
5757

@@ -145,12 +145,12 @@ async def invite_new_user(
145145
(e.g. mobile message).
146146
"""
147147
project = project_user_dict.get("project")
148-
osm_user_exists = False
149148

150149
if user_in.osm_username:
151-
if osm_user_exists := await check_osm_user(user_in.osm_username):
150+
if await check_osm_user(user_in.osm_username):
152151
username = user_in.osm_username
153152
signin_type = "osm"
153+
domain = settings.FMTM_DOMAIN
154154
else:
155155
raise HTTPException(
156156
status_code=HTTPStatus.NOT_FOUND,
@@ -159,6 +159,9 @@ async def invite_new_user(
159159
elif user_in.email:
160160
username = user_in.email.split("@")[0]
161161
signin_type = "google"
162+
# We use different domain for non OSM users since they can't access the
163+
# management interface
164+
domain = f"mapper.{settings.FMTM_DOMAIN}"
162165
else:
163166
raise HTTPException(
164167
status_code=HTTPStatus.BAD_REQUEST,
@@ -178,36 +181,26 @@ async def invite_new_user(
178181
},
179182
)
180183

184+
# Generate invite URL
181185
new_invite = await DbUserInvite.create(db, project.id, user_in)
182186

183-
# Generate invite URL
184-
# TODO create frontend page to handle /invite
185-
# TODO save token from URL in localStorage
186-
# TODO ask user to login to Field-TM first, present options
187-
# TODO once logged in and redirected back to frontend
188-
# TODO read the localStorage `invite` key, and call the
189-
# TODO /users/invite/{token} endpoint.
190187
if settings.DEBUG:
191188
invite_url = (
192-
f"http://{settings.FMTM_DOMAIN}:{settings.FMTM_DEV_PORT}"
193-
f"/invite?token={new_invite.token}"
189+
f"http://{domain}:{settings.FMTM_DEV_PORT}/invite?token={new_invite.token}"
194190
)
195191
else:
196-
invite_url = f"https://{settings.FMTM_DOMAIN}/invite?token={new_invite.token}"
197-
198-
# Notify via OSM message
199-
if osm_user_exists:
200-
background_tasks.add_task(
201-
send_invitation_osm_message,
202-
request=request,
203-
project=project,
204-
invitee_username=username,
205-
osm_auth=osm_auth,
206-
invite_url=invite_url,
207-
)
208-
209-
# TODO Notify via email (consider options)
210-
192+
invite_url = f"https://{domain}/invite?token={new_invite.token}"
193+
194+
background_tasks.add_task(
195+
send_invitation_message,
196+
request=request,
197+
project=project,
198+
invitee_username=username,
199+
osm_auth=osm_auth,
200+
invite_url=invite_url,
201+
user_email=user_in.email,
202+
signin_type=signin_type,
203+
)
211204
return JSONResponse(status_code=HTTPStatus.OK, content={"invite_url": invite_url})
212205

213206

0 commit comments

Comments
 (0)