Skip to content
Closed
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
40 changes: 40 additions & 0 deletions backend/app/api/routes/users.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import csv
import io
import uuid
from typing import Any

from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlmodel import col, delete, func, select

from app import crud
Expand Down Expand Up @@ -159,6 +162,43 @@ def register_user(session: SessionDep, user_in: UserRegister) -> Any:
return user


@router.get(
"/export",
dependencies=[Depends(get_current_active_superuser)],
response_class=StreamingResponse,
)
def export_users(session: SessionDep) -> StreamingResponse:
"""
Export all users as a downloadable CSV file. Superusers only.
"""
statement = select(User).order_by(col(User.created_at).desc())
users = session.exec(statement).all()

output = io.StringIO()
writer = csv.writer(output)
writer.writerow(
["id", "email", "full_name", "is_active", "is_superuser", "created_at"]
)
for user in users:
writer.writerow(
[
str(user.id),
user.email,
user.full_name or "",
user.is_active,
user.is_superuser,
user.created_at.isoformat() if user.created_at else "",
]
)

output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="users.csv"'},
)


@router.get("/{user_id}", response_model=UserPublic)
def read_user_by_id(
user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser
Expand Down
37 changes: 37 additions & 0 deletions backend/tests/api/routes/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,43 @@ def test_delete_user_me(client: TestClient, db: Session) -> None:
assert user_db is None


def test_export_users_as_superuser(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
# Create a known user so there is at least one row in the export
username = random_email()
password = random_lower_string()
user_in = UserCreate(email=username, password=password)
crud.create_user(session=db, user_create=user_in)

r = client.get(
f"{settings.API_V1_STR}/users/export",
headers=superuser_token_headers,
)
assert r.status_code == 200
assert "text/csv" in r.headers["content-type"]
assert "attachment" in r.headers["content-disposition"]
assert "users.csv" in r.headers["content-disposition"]

lines = r.text.strip().splitlines()
assert lines[0] == "id,email,full_name,is_active,is_superuser,created_at"
assert len(lines) >= 2 # header + at least the user we created

# Verify the created user appears in the CSV
emails_in_csv = [line.split(",")[1] for line in lines[1:]]
assert username in emails_in_csv


def test_export_users_forbidden_for_normal_user(
client: TestClient, normal_user_token_headers: dict[str, str]
) -> None:
r = client.get(
f"{settings.API_V1_STR}/users/export",
headers=normal_user_token_headers,
)
assert r.status_code == 403


def test_delete_user_me_as_superuser(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/client/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersExportUsersResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';

export class ItemsService {
/**
Expand Down Expand Up @@ -365,6 +365,19 @@ export class UsersService {
});
}

/**
* Export Users
* Export all users as a downloadable CSV file. Superusers only.
* @returns unknown Successful Response
* @throws ApiError
*/
public static exportUsers(): CancelablePromise<UsersExportUsersResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/users/export'
});
}

/**
* Read User By Id
* Get a specific user by id.
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/client/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ export type UsersRegisterUserData = {

export type UsersRegisterUserResponse = (UserPublic);

export type UsersExportUsersResponse = (unknown);

export type UsersReadUserByIdData = {
userId: string;
};
Expand Down
Loading