Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5ca2e34
make empty strings hide social links
jacobwhall Aug 14, 2025
66308bb
backend: Add ability to delete a user
tsmock Apr 10, 2024
fdd5fcb
backend: Use planet.osm.org/users_deleted/users_deleted.txt to avoid …
tsmock Apr 11, 2024
fb1f325
Initial UI work
tsmock Apr 17, 2024
55a6e37
Ability to deletion user information
prabinoid Sep 16, 2025
04684ce
delete user api updated
prabinoid Sep 23, 2025
9991689
fix confirmation message typo
suzit-10 Oct 8, 2025
b4b439d
Remove unused props `name` from DeleteModal component
suzit-10 Oct 8, 2025
0456ac9
Add delete button on each row of users table and add DeleteModal as a…
suzit-10 Oct 8, 2025
3c7fbd5
Adjust test case as per user deletion changes
suzit-10 Oct 9, 2025
8dfd566
do not show any social links by default
jacobwhall Nov 6, 2025
ac9cd27
remove osm deleted/gone user deletion streaming api
prabinoid Nov 6, 2025
0aa6c00
flake8 import issue for test resource
prabinoid Nov 11, 2025
1e27bf9
Merge pull request #7004 from MapRVA/exclude-social-networks
spwoodcock Dec 12, 2025
cf88b00
save comment value insted of comment input event value on session sto…
suzit-10 Dec 14, 2025
6a90b18
Save task comment on session storage for persistance on reload or com…
suzit-10 Dec 14, 2025
2262d0c
fix: user attribute in user license interpreted as postgres user
prabinoid Dec 15, 2025
25d4b90
implement message persisting behaviour on task comment by saving mess…
suzit-10 Dec 15, 2025
c6fa6d3
change tooltip message `delete` to `delete user`
suzit-10 Dec 15, 2025
5f2cae4
right align the user delete button
suzit-10 Dec 22, 2025
731aff7
adapt test cases
suzit-10 Dec 23, 2025
0018a61
Merge pull request #7117 from hotosm/fix/6684-persist-unposted-commen…
ramyaragupathy Dec 23, 2025
85427d7
update delete confirmation message
suzit-10 Dec 23, 2025
b47a330
update delete button text on user delete
suzit-10 Dec 23, 2025
a828415
Merge pull request #7070 from hotosm/pr-6334
ramyaragupathy Dec 24, 2025
dcb7cc7
Merge pull request #7119 from hotosm/develop
ramyaragupathy Dec 29, 2025
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
156 changes: 152 additions & 4 deletions backend/api/users/resources.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
from typing import Optional
import json
from typing import Any, Iterable, Optional, AsyncGenerator

from backend.models.postgis.user import User
from databases import Database
from fastapi import APIRouter, Depends, Request, Query, Path
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Depends, Request, Query, Path, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from loguru import logger

from backend.db import get_db
from backend.models.dtos.user_dto import AuthUserDTO, UserSearchQuery
from backend.services.project_service import ProjectService
from backend.services.users.authentication_service import login_required
from backend.services.users.user_service import UserService
from backend.services.users.osm_service import OSMService, OSMServiceError
from backend.db import db_connection

router = APIRouter(
prefix="/users",
Expand Down Expand Up @@ -59,6 +62,55 @@ async def get_user(
return user_dto


@router.delete("/{user_id}/", tags=["users"])
async def delete_user(
user_id: int,
user: AuthUserDTO = Depends(login_required),
db: Database = Depends(get_db),
):
"""
Delete user information by id.

- **user_id**: The id of the user to delete.
- Returns the deleted user object (primitive form) and HTTP 200 on success.

Responses:
200: User deleted
401: Unauthorized - insufficient permissions
404: User not found
500: Internal Server Error
"""
# Only the user themself or an admin may delete
is_admin = await UserService.is_user_an_admin(user.id, db)

if user_id != user.id and not is_admin:

return JSONResponse(
content={
"Error": "User not permitted",
"SubCode": "UserPermissionError",
},
status_code=401,
)

try:
deleted_dto = await UserService.delete_user_by_id(user_id, user.id, db)
if deleted_dto is None:
return JSONResponse(
content={
"Error": "User not found",
"SubCode": "UserNotFound",
},
status_code=400,
)

return deleted_dto

except Exception as exc:
logger.exception("Failed to delete user %s: %s", user_id, exc)
raise HTTPException(status_code=500, detail="Internal Server Error")


@router.get("/")
async def list_users(
page: int = Query(1, description="Page of results user requested"),
Expand Down Expand Up @@ -146,6 +198,102 @@ async def list_users(
return users_dto


async def _aiter_from_sync_iterable(
iterable: Iterable[Any],
) -> AsyncGenerator[Any, None]:
for item in iterable:
yield item


# @router.delete("/", tags=["users"])
async def delete_users(
user: AuthUserDTO = Depends(login_required),
db: Database = Depends(get_db),
) -> StreamingResponse:
# permission check remains the same
is_admin = await UserService.is_user_an_admin(user.id, db)
if not is_admin:
return JSONResponse(
content={"Error": "User not permitted", "SubCode": "UserPermissionError"},
status_code=401,
)

async def _delete_users_gen() -> AsyncGenerator[bytes, None]:
"""
Acquire a DB connection for the lifetime of this generator so fetches
and iterations don't fail with "Connection is not acquired".
"""
deleted_users_gen = OSMService.get_deleted_users()

async with db_connection.database.connection() as conn:

users_iterable_or_aiter = await User.get_all_users_not_paginated(conn)
if hasattr(users_iterable_or_aiter, "__aiter__"):
users_async_iter = users_iterable_or_aiter # type: ignore
else:
users_async_iter = _aiter_from_sync_iterable(users_iterable_or_aiter)

if deleted_users_gen is not None:
last_deleted = 0
try:
async for user_rec in users_async_iter:
user_id = (
user_rec.get("id")
if isinstance(user_rec, dict)
else getattr(user_rec, "id", None)
)
if user_id is None:
continue

try:
while last_deleted < user_id:
last_deleted = await deleted_users_gen.__anext__()
except StopAsyncIteration:
return

if last_deleted == user_id:
deleted_dto = await UserService.delete_user_by_id(
user_id, user, conn
)
primitive = (
deleted_dto.to_primitive()
if hasattr(deleted_dto, "to_primitive")
else deleted_dto
)
yield (f"\u001e{json.dumps(primitive)}\n").encode("utf-8")
finally:
if hasattr(deleted_users_gen, "aclose"):
await deleted_users_gen.aclose()

return

async for user_rec in users_async_iter:
user_id = (
user_rec.get("id")
if isinstance(user_rec, dict)
else getattr(user_rec, "id", None)
)
if user_id is None:
continue
try:
gone = await OSMService.is_osm_user_gone(user_id)
except OSMServiceError:
continue

if gone:
deleted_dto = await UserService.delete_user_by_id(
user_id, user, conn
)
primitive = (
deleted_dto.to_primitive()
if hasattr(deleted_dto, "to_primitive")
else deleted_dto
)
yield (f"\u001e{json.dumps(primitive)}\n").encode("utf-8")

return StreamingResponse(_delete_users_gen(), media_type="application/json-seq")


@router.get("/queries/favorites/")
async def get_user_favorite_projects(
request: Request,
Expand Down
10 changes: 10 additions & 0 deletions backend/models/postgis/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ async def get_all_for_user(user: int, db: Database):
applications_dto.applications.append(application_dto)
return applications_dto

@staticmethod
async def delete_all_for_user(user_id: int, db: Database) -> None:
"""
Delete all Application rows for the given user in one async transaction.
Pass `db` (from your get_db dependency).
"""
query = 'DELETE FROM application_keys WHERE "user" = :user'
async with db.transaction():
await db.execute(query=query, values={"user": user_id})

def as_dto(self):
app_dto = ApplicationDTO()
app_dto.user = self.user
Expand Down
51 changes: 51 additions & 0 deletions backend/services/users/osm_service.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import re
from typing import AsyncGenerator, Optional

import requests
from loguru import logger

from backend.config import settings
from backend.models.dtos.user_dto import UserOSMDTO
import httpx


class OSMServiceError(Exception):
Expand All @@ -13,6 +17,51 @@ def __init__(self, message):


class OSMService:
@staticmethod
async def is_osm_user_gone(user_id: int) -> bool:
"""
Async HEAD request to check OSM user status.
Returns True for 410, False for 200, raise OSMServiceError otherwise.
"""
osm_user_details_url = f"{settings.OSM_SERVER_URL}/api/0.6/user/{user_id}.json"
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.head(osm_user_details_url, follow_redirects=True)
if resp.status_code == 410:
return True
if resp.status_code == 200:
return False
# treat other statuses as an error so caller can decide
raise OSMServiceError(f"Bad response from OSM: {resp.status_code}")

@staticmethod
def get_deleted_users() -> Optional[AsyncGenerator[int, None]]:
"""
Return an async generator yielding deleted user IDs (ascending).
If not using https://www.openstreetmap.org as OSM_SERVER_URL, return None
(matching original behaviour).
"""
if settings.OSM_SERVER_URL != "https://www.openstreetmap.org":
return None

async def _gen() -> AsyncGenerator[int, None]:
url = "https://planet.openstreetmap.org/users_deleted/users_deleted.txt"
username_re = re.compile(r"^\s*(\d+)\s*$")
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("GET", url) as resp:
if resp.status_code != 200:
# Fail fast β€” caller can handle OSMServiceError
raise OSMServiceError(
f"Failed fetching deleted users: {resp.status_code}"
)
async for line in resp.aiter_lines():
if not line:
continue
m = username_re.fullmatch(line)
if m:
yield int(m.group(1))

return _gen()

@staticmethod
def get_osm_details_for_user(user_id: int) -> UserOSMDTO:
"""
Expand All @@ -23,6 +72,8 @@ def get_osm_details_for_user(user_id: int) -> UserOSMDTO:
osm_user_details_url = f"{settings.OSM_SERVER_URL}/api/0.6/user/{user_id}.json"
response = requests.get(osm_user_details_url)

if response.status_code == 410:
raise OSMServiceError("User no longer exists on OSM")
if response.status_code != 200:
raise OSMServiceError("Bad response from OSM")

Expand Down
Loading
Loading