Skip to content

Commit

Permalink
✨(api) add users endpoint for deletion management
Browse files Browse the repository at this point in the history
Adding:
- GET /users endpoint to retrieve the user list by deletion status on a
service
- PATCH /users/{user_id} endpoint to update a deletion status for a service
  • Loading branch information
wilbrdt committed Nov 12, 2024
1 parent 04edf1d commit bd980ed
Show file tree
Hide file tree
Showing 6 changed files with 830 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to
## Added

- Add edx mongodb connection to anonymize personal data from edx forums
- Add `users` endpoints for services to retrieve the list of users to delete

## Changed

Expand Down
3 changes: 2 additions & 1 deletion src/app/mork/api/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

from fastapi import FastAPI

from mork.api.v1.routers import tasks
from mork.api.v1.routers import tasks, users

app = FastAPI(title="Mork API (v1)")

app.include_router(tasks.router)
app.include_router(users.router)
174 changes: 174 additions & 0 deletions src/app/mork/api/v1/routers/users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""API routes related to users."""

import logging
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, status
from sqlalchemy import select, update
from sqlalchemy.exc import NoResultFound
from sqlalchemy.orm import Session

from mork.auth import authenticate_api_key
from mork.db import get_session
from mork.models.users import (
ServiceName,
User,
UserServiceStatus,
)
from mork.schemas.users import (
DeletionStatus,
UserRead,
UserStatusRead,
UserStatusUpdate,
)

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/users", dependencies=[Depends(authenticate_api_key)])


@router.get("")
@router.get("/")
async def read_users(
session: Annotated[Session, Depends(get_session)],
service: Annotated[
ServiceName | None,
Query(description="The name of the service to filter users on"),
] = None,
deletion_status: Annotated[
DeletionStatus | None,
Query(description="The deletion status to filter users on"),
] = None,
offset: Annotated[
int | None,
Query(ge=0, description="The number of items to offset"),
] = 0,
limit: Annotated[
int | None,
Query(le=1000, description="The maximum number of items to retrieve"),
] = 100,
) -> list[UserRead]:
"""Retrieve a list of users based on the query parameters."""
statement = select(User)

if service or deletion_status:
statement = statement.join(UserServiceStatus)

if service:
statement = statement.where(UserServiceStatus.service_name == service)

if deletion_status:
statement = statement.where(UserServiceStatus.status == deletion_status)

users = session.scalars(statement.offset(offset).limit(limit)).unique().all()

response_users = [UserRead.model_validate(user) for user in users]
logger.debug("Results = %s", response_users)
return response_users


@router.get("/{user_id}")
async def read_user(
session: Annotated[Session, Depends(get_session)],
user_id: Annotated[UUID, Path(description="The id of the user to read")],
service: Annotated[
ServiceName | None,
Query(description="The name of the service to filter users on"),
] = None,
) -> UserRead:
"""Retrieve the user from its id."""
statement = select(User).where(User.id == user_id)

if service:
statement = statement.join(UserServiceStatus).where(
UserServiceStatus.service_name == service
)

user = session.scalar(statement)

if not user:
message = "User not found"
logger.debug("%s: %s", message, user_id)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=message)

response_user = UserRead.model_validate(user)
logger.debug("Result = %s", response_user)
return response_user


@router.get("/{user_id}/status/{service_name}")
async def read_user_status(
session: Annotated[Session, Depends(get_session)],
user_id: Annotated[
UUID, Path(description="The ID of the user to read status from")
],
service_name: Annotated[
ServiceName,
Path(description="The name of the service making the request"),
],
) -> UserStatusRead:
"""Read the user deletion status for a specific service."""
statement = select(UserServiceStatus).where(
UserServiceStatus.user_id == user_id,
UserServiceStatus.service_name == service_name,
)

service_status = session.scalar(statement)

if not service_status:
message = "User status not found"
logger.debug("%s: %s %s", message, user_id, service_name)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=message)

response = UserStatusRead(
id=service_status.user_id,
service_name=service_status.service_name,
status=service_status.status,
)
logger.debug("Results = %s", response)

return response


@router.patch("/{user_id}/status/{service_name}")
async def update_user_status(
session: Annotated[Session, Depends(get_session)],
user_id: Annotated[UUID, Path(title="The ID of the user to update status")],
service_name: Annotated[
ServiceName,
Path(description="The name of the service to update status"),
],
deletion_status: Annotated[
DeletionStatus,
Body(description="The new deletion status", embed=True),
],
) -> UserStatusUpdate:
"""Update the user deletion status for a specific service."""
statement = (
update(UserServiceStatus)
.where(
UserServiceStatus.user_id == user_id,
UserServiceStatus.service_name == service_name,
)
.values(status=deletion_status)
.returning(UserServiceStatus)
)

try:
updated = session.execute(statement).scalars().one()
except NoResultFound as exc:
message = "User status not found"
logger.debug("%s: %s", message, user_id)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=message
) from exc

response_user = UserStatusUpdate(
id=updated.user_id,
service_name=updated.service_name,
status=updated.status,
)
logger.debug("Results = %s", response_user)

return response_user
1 change: 1 addition & 0 deletions src/app/mork/models/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class DeletionStatus(str, enum.Enum):
"""Enum for deletion statuses."""

TO_DELETE = "to_delete"
DELETING = "deleting"
DELETED = "deleted"


Expand Down
49 changes: 49 additions & 0 deletions src/app/mork/schemas/users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Mork users schemas."""

from uuid import UUID

from pydantic import BaseModel, ConfigDict, EmailStr

from mork.models.users import DeletionReason, DeletionStatus, ServiceName


class UserServiceStatusRead(BaseModel):
"""Model for reading service statuses of a user."""

model_config = ConfigDict(from_attributes=True)

service_name: ServiceName
status: DeletionStatus


class UserRead(BaseModel):
"""Model for reading detailed information about a user."""

model_config = ConfigDict(from_attributes=True)

id: UUID
username: str
edx_user_id: int
email: EmailStr
reason: DeletionReason
service_statuses: list[UserServiceStatusRead]


class UserStatusRead(BaseModel):
"""Model for reading a user status."""

model_config = ConfigDict(from_attributes=True)

id: UUID
service_name: ServiceName
status: DeletionStatus


class UserStatusUpdate(BaseModel):
"""Model for response after updating a user status."""

model_config = ConfigDict(from_attributes=True)

id: UUID
service_name: ServiceName
status: DeletionStatus
Loading

0 comments on commit bd980ed

Please sign in to comment.