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
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