Skip to content
Open
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
3 changes: 2 additions & 1 deletion backend/app/api/main.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from fastapi import APIRouter

from app.api.routes import items, login, users, utils
from app.api.routes import items, login, search, users, utils

api_router = APIRouter()
api_router.include_router(login.router, tags=["login"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(utils.router, prefix="/utils", tags=["utils"])
api_router.include_router(items.router, prefix="/items", tags=["items"])
api_router.include_router(search.router, prefix="/search", tags=["search"])
70 changes: 70 additions & 0 deletions backend/app/api/routes/search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from typing import Any

from fastapi import APIRouter, Query
from sqlalchemy import or_
from sqlmodel import col, select

from app.api.deps import CurrentUser, SessionDep
from app.models import Item, SearchResult, SearchResults, User

router = APIRouter()


@router.get("/", response_model=SearchResults)
def search(
session: SessionDep,
current_user: CurrentUser,
q: str = Query(min_length=1, max_length=100),
limit: int = Query(default=8, ge=1, le=25),
) -> Any:
"""
Search records visible to the current user.
"""
query = q.strip()
if not query:
return SearchResults(data=[], count=0)

pattern = f"%{query}%"
item_statement = select(Item).where(
or_(
col(Item.title).ilike(pattern),
col(Item.description).ilike(pattern),
)
)
if not current_user.is_superuser:
item_statement = item_statement.where(Item.owner_id == current_user.id)

items = session.exec(item_statement.limit(limit)).all()
results: list[SearchResult] = [
SearchResult(
id=f"item-{item.id}",
title=item.title,
description=item.description or f"Item #{item.id}",
category="Item",
path="/items",
)
for item in items
]

if current_user.is_superuser and len(results) < limit:
remaining = limit - len(results)
user_statement = select(User).where(
or_(
col(User.email).ilike(pattern),
col(User.full_name).ilike(pattern),
)
)
users = session.exec(user_statement.limit(remaining)).all()
results.extend(
SearchResult(
id=f"user-{user.id}",
title=user.full_name or user.email,
description=f"{user.email} - "
f"{'Superuser' if user.is_superuser else 'User'}",
category="User",
path="/admin",
)
for user in users
)

return SearchResults(data=results, count=len(results))
13 changes: 13 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ class ItemsPublic(SQLModel):
count: int


class SearchResult(SQLModel):
id: str
title: str
description: str | None = None
category: str
path: str


class SearchResults(SQLModel):
data: list[SearchResult]
count: int


# Generic message
class Message(SQLModel):
message: str
Expand Down
83 changes: 83 additions & 0 deletions backend/app/tests/api/routes/test_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from fastapi.testclient import TestClient
from sqlmodel import Session

from app import crud
from app.core.config import settings
from app.models import ItemCreate
from app.tests.utils.user import create_random_user


def test_search_returns_visible_items_for_normal_user(
client: TestClient,
normal_user_token_headers: dict[str, str],
db: Session,
) -> None:
current_user = crud.get_user_by_email(session=db, email=settings.EMAIL_TEST_USER)
assert current_user is not None
assert current_user.id is not None

visible_item = crud.create_item(
session=db,
item_in=ItemCreate(
title="Visible Alpha Search Item",
description="Normal user can find this",
),
owner_id=current_user.id,
)
other_user = create_random_user(db)
assert other_user.id is not None
crud.create_item(
session=db,
item_in=ItemCreate(
title="Hidden Alpha Search Item",
description="Normal user cannot find this",
),
owner_id=other_user.id,
)

response = client.get(
f"{settings.API_V1_STR}/search/",
headers=normal_user_token_headers,
params={"q": "Alpha Search"},
)

assert response.status_code == 200
content = response.json()
result_ids = {result["id"] for result in content["data"]}
assert f"item-{visible_item.id}" in result_ids
assert all(result["title"] != "Hidden Alpha Search Item" for result in content["data"])


def test_search_returns_users_only_for_superuser(
client: TestClient,
superuser_token_headers: dict[str, str],
normal_user_token_headers: dict[str, str],
db: Session,
) -> None:
user = create_random_user(db)
user.full_name = "Unique Search Person"
db.add(user)
db.commit()
db.refresh(user)

superuser_response = client.get(
f"{settings.API_V1_STR}/search/",
headers=superuser_token_headers,
params={"q": "Unique Search Person"},
)
normal_user_response = client.get(
f"{settings.API_V1_STR}/search/",
headers=normal_user_token_headers,
params={"q": "Unique Search Person"},
)

assert superuser_response.status_code == 200
assert normal_user_response.status_code == 200
assert any(
result["id"] == f"user-{user.id}"
for result in superuser_response.json()["data"]
)
assert all(
result["id"] != f"user-{user.id}"
for result in normal_user_response.json()["data"]
)
14 changes: 13 additions & 1 deletion frontend/src/client/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ export type ItemsPublic = {
count: number;
};

export type SearchResult = {
id: string;
title: string;
description?: string | null;
category: string;
path: string;
};

export type SearchResults = {
data: Array<SearchResult>;
count: number;
};



export type Message = {
Expand Down Expand Up @@ -129,4 +142,3 @@ export type ValidationError = {
msg: string;
type: string;
};

36 changes: 34 additions & 2 deletions frontend/src/client/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';

import type { Body_login_login_access_token,Message,NewPassword,Token,UserPublic,UpdatePassword,UserCreate,UserRegister,UsersPublic,UserUpdate,UserUpdateMe,ItemCreate,ItemPublic,ItemsPublic,ItemUpdate } from './models';
import type { Body_login_login_access_token,Message,NewPassword,SearchResults,Token,UserPublic,UpdatePassword,UserCreate,UserRegister,UsersPublic,UserUpdate,UserUpdateMe,ItemCreate,ItemPublic,ItemsPublic,ItemUpdate } from './models';

export type TDataLoginAccessToken = {
formData: Body_login_login_access_token
Expand Down Expand Up @@ -383,6 +383,38 @@ emailTo,

}

export type TDataSearch = {
limit?: number
q: string
}

export class SearchService {

/**
* Search
* Search records visible to the current user.
* @returns SearchResults Successful Response
* @throws ApiError
*/
public static search(data: TDataSearch): CancelablePromise<SearchResults> {
const {
limit = 8,
q,
} = data;
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/search/',
query: {
q, limit
},
errors: {
422: `Validation Error`,
},
});
}

}

export type TDataReadItems = {
limit?: number
skip?: number
Expand Down Expand Up @@ -521,4 +553,4 @@ id,
});
}

}
}
Loading