Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1a5dea6
fix(flows): replace GET /flows/exists with POST to support URI-unsafe…
Mar 7, 2026
2f60ac4
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 7, 2026
3ff845f
style: fix trailing comma in flow_exists db call
Mar 7, 2026
a771b69
refactor(flows): move FlowExistsBody to schemas and keep GET as depre…
Mar 7, 2026
9662c1f
feat: add DELETE /users/{user_id} endpoint (Phase 1, fixes #194)
Jayant-kernel Mar 7, 2026
be4980e
fix(review): address PR feedback on account deletion
Jayant-kernel Mar 7, 2026
6449a2e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 7, 2026
ec59247
fix(types): specify generic type parameters for dict in users router
Jayant-kernel Mar 7, 2026
e8fd89f
style: remove inline comments to adhere to contribution guidelines
Jayant-kernel Mar 7, 2026
aa4ed9c
fix(review): implement session locking and add missing regression tests
Jayant-kernel Mar 7, 2026
11e3c99
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 7, 2026
d175de1
style: fix ruff E501 and PLR0913 in users tests
Jayant-kernel Mar 7, 2026
171c9b3
style: fix ruff ARG001 unused argument lint error
Jayant-kernel Mar 7, 2026
f0999e5
fix(review): address CodeRabbit actionable comments
Jayant-kernel Mar 7, 2026
932a2dc
Fix user delete lock restore race and tighten resource-block tests
Mar 10, 2026
0978b8d
fix(users): commit deletion lock before resource check
Mar 11, 2026
1a60e2e
Merge upstream/main into feature/delete-account-endpoint
Jayant-kernel Mar 15, 2026
3345fe1
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 15, 2026
7888523
Fix docstring imperative mood for deprecated flows endpoint
Jayant-kernel Mar 15, 2026
0ecb8b1
Add docstrings for users router
Jayant-kernel Mar 15, 2026
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
6 changes: 6 additions & 0 deletions src/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ class DatasetError(IntEnum):
NOT_FOUND = 111
NO_ACCESS = 112
NO_DATA_FILE = 113


class UserError(IntEnum):
NOT_FOUND = 120
NO_ACCESS = 121
HAS_RESOURCES = 122
29 changes: 29 additions & 0 deletions src/database/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,32 @@ def groups(self) -> list[UserGroup]:
groups = get_user_groups_for(user_id=self.user_id, connection=self._database)
self._groups = [UserGroup(group_id) for group_id in groups]
return self._groups


def get_user_resource_count(*, user_id: int, expdb: Connection) -> int:
"""Return the total number of datasets, flows, and runs owned by the user."""
dataset_count = expdb.execute(
text("SELECT COUNT(*) FROM dataset WHERE uploader = :user_id"),
parameters={"user_id": user_id},
).scalar() or 0
flow_count = expdb.execute(
text("SELECT COUNT(*) FROM implementation WHERE uploader = :user_id"),
parameters={"user_id": user_id},
).scalar() or 0
run_count = expdb.execute(
text("SELECT COUNT(*) FROM run WHERE uploader = :user_id"),
parameters={"user_id": user_id},
).scalar() or 0
return int(dataset_count) + int(flow_count) + int(run_count)


def delete_user(*, user_id: int, connection: Connection) -> None:
"""Remove the user and their group memberships from the user database."""
connection.execute(
text("DELETE FROM users_groups WHERE user_id = :user_id"),
parameters={"user_id": user_id},
)
connection.execute(
text("DELETE FROM users WHERE id = :user_id"),
parameters={"user_id": user_id},
)
2 changes: 2 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from routers.openml.study import router as study_router
from routers.openml.tasks import router as task_router
from routers.openml.tasktype import router as ttype_router
from routers.openml.users import router as users_router


def _parse_args() -> argparse.Namespace:
Expand Down Expand Up @@ -55,6 +56,7 @@ def create_api() -> FastAPI:
app.include_router(task_router)
app.include_router(flows_router)
app.include_router(study_router)
app.include_router(users_router)
return app


Expand Down
23 changes: 18 additions & 5 deletions src/routers/openml/flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,22 @@
import database.flows
from core.conversions import _str_to_num
from routers.dependencies import expdb_connection
from schemas.flows import Flow, Parameter, Subflow
from schemas.flows import Flow, FlowExistsBody, Parameter, Subflow

router = APIRouter(prefix="/flows", tags=["flows"])


@router.get("/exists/{name}/{external_version}")
@router.post("/exists")
def flow_exists(
name: str,
external_version: str,
body: FlowExistsBody,
expdb: Annotated[Connection, Depends(expdb_connection)],
) -> dict[Literal["flow_id"], int]:
"""Check if a Flow with the name and version exists, if so, return the flow id."""
flow = database.flows.get_by_name(name=name, external_version=external_version, expdb=expdb)
flow = database.flows.get_by_name(
name=body.name,
external_version=body.external_version,
expdb=expdb,
)
if flow is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
Expand All @@ -28,6 +31,16 @@ def flow_exists(
return {"flow_id": flow.id}


@router.get("/exists/{name}/{external_version}", deprecated=True)
def flow_exists_get(
name: str,
external_version: str,
expdb: Annotated[Connection, Depends(expdb_connection)],
) -> dict[Literal["flow_id"], int]:
"""Deprecated: use POST /flows/exists instead."""
return flow_exists(FlowExistsBody(name=name, external_version=external_version), expdb)


@router.get("/{flow_id}")
def get_flow(flow_id: int, expdb: Annotated[Connection, Depends(expdb_connection)] = None) -> Flow:
flow = database.flows.get(flow_id, expdb)
Expand Down
76 changes: 76 additions & 0 deletions src/routers/openml/users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from http import HTTPStatus
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import Connection

from core.errors import UserError
from database.users import UserGroup, delete_user, get_user_resource_count
from routers.dependencies import expdb_connection, fetch_user, userdb_connection
from database.users import User

router = APIRouter(prefix="/users", tags=["users"])


@router.delete(
"/{user_id}",
summary="Delete a user account",
description=(
"Deletes the account of the specified user. "
"Only the account owner or an admin may perform this action. "
"Deletion is blocked if the user has uploaded any datasets, flows, or runs."
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
),
)
def delete_account(
user_id: int,
caller: Annotated[User | None, Depends(fetch_user)] = None,
user_db: Annotated[Connection, Depends(userdb_connection)] = None,
expdb: Annotated[Connection, Depends(expdb_connection)] = None,
) -> dict:
if caller is None:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail={"code": str(int(UserError.NO_ACCESS)), "message": "Authentication required"},
)

is_admin = UserGroup.ADMIN in caller.groups
is_self = caller.user_id == user_id

if not is_admin and not is_self:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail={"code": str(int(UserError.NO_ACCESS)), "message": "No access granted"},
)

# Verify the target user exists
from database.users import get_user_id_for # noqa: PLC0415

# Check user exists by querying for them directly
from sqlalchemy import text

existing = user_db.execute(
text("SELECT id FROM users WHERE id = :user_id"),
parameters={"user_id": user_id},
).one_or_none()

if existing is None:
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail={"code": str(int(UserError.NOT_FOUND)), "message": "User not found"},
)

resource_count = get_user_resource_count(user_id=user_id, expdb=expdb)
if resource_count > 0:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail={
"code": str(int(UserError.HAS_RESOURCES)),
"message": (
f"User has {resource_count} resource(s). "
"Remove or transfer resources before deleting the account."
),
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
},
)

delete_user(user_id=user_id, connection=user_db)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return {"user_id": user_id, "deleted": True}
5 changes: 5 additions & 0 deletions src/schemas/flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
from pydantic import BaseModel, ConfigDict, Field


class FlowExistsBody(BaseModel):
name: str
external_version: str


class Parameter(BaseModel):
name: str
default_value: Any
Expand Down
15 changes: 10 additions & 5 deletions tests/routers/openml/flows_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from starlette.testclient import TestClient

from routers.openml.flows import flow_exists
from schemas.flows import FlowExistsBody
from tests.conftest import Flow


Expand All @@ -25,7 +26,7 @@ def test_flow_exists_calls_db_correctly(
mocker: MockerFixture,
) -> None:
mocked_db = mocker.patch("database.flows.get_by_name")
flow_exists(name, external_version, expdb_test)
flow_exists(FlowExistsBody(name=name, external_version=external_version), expdb_test)
mocked_db.assert_called_once_with(
name=name,
external_version=external_version,
Expand All @@ -47,26 +48,30 @@ def test_flow_exists_processes_found(
"database.flows.get_by_name",
return_value=fake_flow,
)
response = flow_exists("name", "external_version", expdb_test)
response = flow_exists(
FlowExistsBody(name="name", external_version="external_version"), expdb_test
)
assert response == {"flow_id": fake_flow.id}


def test_flow_exists_handles_flow_not_found(mocker: MockerFixture, expdb_test: Connection) -> None:
mocker.patch("database.flows.get_by_name", return_value=None)
with pytest.raises(HTTPException) as error:
flow_exists("foo", "bar", expdb_test)
flow_exists(FlowExistsBody(name="foo", external_version="bar"), expdb_test)
assert error.value.status_code == HTTPStatus.NOT_FOUND
assert error.value.detail == "Flow not found."


def test_flow_exists(flow: Flow, py_api: TestClient) -> None:
response = py_api.get(f"/flows/exists/{flow.name}/{flow.external_version}")
response = py_api.post(
"/flows/exists", json={"name": flow.name, "external_version": flow.external_version}
)
assert response.status_code == HTTPStatus.OK
assert response.json() == {"flow_id": flow.id}


def test_flow_exists_not_exists(py_api: TestClient) -> None:
response = py_api.get("/flows/exists/foo/bar")
response = py_api.post("/flows/exists", json={"name": "foo", "external_version": "bar"})
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.json()["detail"] == "Flow not found."

Expand Down
15 changes: 9 additions & 6 deletions tests/routers/openml/migration/flows_migration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@ def test_flow_exists_not(
py_api: TestClient,
php_api: TestClient,
) -> None:
path = "exists/foo/bar"
py_response = py_api.get(f"/flows/{path}")
php_response = php_api.get(f"/flow/{path}")
py_response = py_api.post("/flows/exists", json={"name": "foo", "external_version": "bar"})
php_response = php_api.get("/flow/exists/foo/bar")

assert py_response.status_code == HTTPStatus.NOT_FOUND
assert php_response.status_code == HTTPStatus.OK
Expand All @@ -36,9 +35,13 @@ def test_flow_exists(
py_api: TestClient,
php_api: TestClient,
) -> None:
path = f"exists/{persisted_flow.name}/{persisted_flow.external_version}"
py_response = py_api.get(f"/flows/{path}")
php_response = php_api.get(f"/flow/{path}")
py_response = py_api.post(
"/flows/exists",
json={"name": persisted_flow.name, "external_version": persisted_flow.external_version},
)
php_response = php_api.get(
f"/flow/exists/{persisted_flow.name}/{persisted_flow.external_version}"
)

assert py_response.status_code == php_response.status_code, php_response.content

Expand Down
85 changes: 59 additions & 26 deletions tests/routers/openml/users_test.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,60 @@
from http import HTTPStatus

import pytest
from sqlalchemy import Connection

from database.users import User
from routers.dependencies import fetch_user
from tests.users import ADMIN_USER, OWNER_USER, SOME_USER, ApiKey


@pytest.mark.parametrize(
("api_key", "user"),
[
(ApiKey.ADMIN, ADMIN_USER),
(ApiKey.OWNER_USER, OWNER_USER),
(ApiKey.SOME_USER, SOME_USER),
],
)
def test_fetch_user(api_key: str, user: User, user_test: Connection) -> None:
db_user = fetch_user(api_key, user_data=user_test)
assert db_user is not None
assert user.user_id == db_user.user_id
assert set(user.groups) == set(db_user.groups)


def test_fetch_user_invalid_key_returns_none(user_test: Connection) -> None:
assert fetch_user(api_key=None, user_data=user_test) is None
invalid_key = "f" * 32
assert fetch_user(api_key=invalid_key, user_data=user_test) is None
from fastapi.testclient import TestClient
from sqlalchemy import Connection, text

from tests.users import ApiKey


@pytest.mark.mut
def test_delete_user_self(py_api: TestClient, user_test: Connection) -> None:
"""A user without resources can delete their own account."""
# Insert a fresh disposable user
user_test.execute(
text(
"INSERT INTO users (session_hash, email, first_name, last_name, password)"
" VALUES ('aaaabbbbccccddddaaaabbbbccccdddd', '[email protected]', 'Del', 'User', 'x')",
),
)
Comment on lines +34 to +42
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (testing): Add a test case for deletion being blocked when the user owns resources (datasets/flows/runs).

Currently there’s no test covering the 409 path when get_user_resource_count > 0. Please add an integration-style test that creates at least one dataset/flow/run owned by the user, attempts deletion (as self and/or admin), and asserts that:

  • the response status is 409
  • the error code is UserError.HAS_RESOURCES
  • the user record remains in the users table.

This helps prevent regressions in the endpoint’s safety guarantees.

(new_id,) = user_test.execute(text("SELECT LAST_INSERT_ID()")).one()

response = py_api.delete(f"/users/{new_id}?api_key=aaaabbbbccccddddaaaabbbbccccdddd")
assert response.status_code == HTTPStatus.OK
assert response.json() == {"user_id": new_id, "deleted": True}


@pytest.mark.mut
def test_delete_user_as_admin(py_api: TestClient, user_test: Connection) -> None:
"""An admin can delete any user without resources."""
user_test.execute(
text(
"INSERT INTO users (session_hash, email, first_name, last_name, password)"
" VALUES ('eeeeffffaaaabbbbeeeeffffaaaabbbb', '[email protected]', 'Del2', 'User', 'x')",
),
)
(new_id,) = user_test.execute(text("SELECT LAST_INSERT_ID()")).one()

response = py_api.delete(f"/users/{new_id}?api_key={ApiKey.ADMIN}")
assert response.status_code == HTTPStatus.OK
assert response.json() == {"user_id": new_id, "deleted": True}


def test_delete_user_no_auth(py_api: TestClient) -> None:
"""No API key → 401."""
response = py_api.delete("/users/2")
assert response.status_code == HTTPStatus.UNAUTHORIZED


def test_delete_user_not_owner(py_api: TestClient) -> None:
"""A non-owner non-admin user cannot delete someone else's account → 403."""
# SOME_USER (user_id=2) tries to delete OWNER_USER (user_id=3229)
response = py_api.delete(f"/users/3229?api_key={ApiKey.SOME_USER}")
assert response.status_code == HTTPStatus.FORBIDDEN


def test_delete_user_not_found(py_api: TestClient) -> None:
"""Deleting a non-existent user → 404."""
response = py_api.delete(f"/users/99999999?api_key={ApiKey.ADMIN}")
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.json()["detail"]["code"] == "120"