feat(backend): add fake data generators

This commit is contained in:
Julian Lobbes 2023-05-21 10:38:27 +02:00
parent 97c9a75ed3
commit 335a97740d
17 changed files with 3336 additions and 98 deletions

View file

@ -1,6 +1,7 @@
alembic==1.10.4
anyio==3.6.2
click==8.1.3
Faker==18.9.0
fastapi==0.95.1
greenlet==2.0.2
h11==0.14.0
@ -9,6 +10,8 @@ Mako==1.2.4
MarkupSafe==2.1.2
psycopg2-binary==2.9.6
pydantic==1.10.7
python-dateutil==2.8.2
six==1.16.0
sniffio==1.3.0
SQLAlchemy==2.0.13
starlette==0.26.1

View file

@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
from todo.models import todos as todomodel
from todo.models import users as usermodel
from todo.schemas import todos as todoschema
from todo.exceptions import NotFoundException, InvalidFilterParameterException
from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException
def create_todo(db: Session, todo: todoschema.TodoItemCreate, user_id: int) -> todoschema.TodoItem:
@ -56,6 +56,16 @@ def read_todos_for_user(db: Session, user_id: int, skip: int = 0, limit: int = 1
return [todoschema.TodoItem.from_orm(db_todo) for db_todo in db_todos]
def read_todos_count_for_user(db: Session, user_id: int) -> int:
"""Returns the total number of todo-items of the user with the specified user_id."""
db_user = db.query(usermodel.User).filter(usermodel.User.id == user_id).first()
if not db_user:
raise NotFoundException(f"User with id '{user_id}' not found.")
return db.query(todomodel.TodoItem).filter(todomodel.TodoItem.user_id == user_id).count()
def update_todo(db: Session, todo: todoschema.TodoItemUpdate, todo_id: int) -> todoschema.TodoItem:
"""Updates the todo-item with the provided id with all non-None fields from the input todo-item."""

View file

@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
from todo.models import users as usermodel
from todo.schemas import users as userschema
from todo.exceptions import NotFoundException, InvalidFilterParameterException
from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException
def hash_password(password: str) -> str:
@ -70,6 +70,12 @@ def read_users(db: Session, skip: int = 0, limit: int = 100) -> list[userschema.
return [userschema.User.from_orm(db_user) for db_user in db_users]
def read_users_count(db: Session) -> int:
"""Returns the total number of users currently the database."""
return db.query(usermodel.User).count()
def update_user(db: Session, user: userschema.UserUpdate, id: int) -> userschema.User:
"""Updates the user with the provided id with all non-None fields from the input user."""

View file

@ -1,19 +0,0 @@
"""This module is a collection of project-wide exceptions."""
class NotFoundException(Exception):
"""Raised when a resource was unexpectedly not found."""
pass
class DataIntegrityException(Exception):
"""Raised to prevent a semantically invalid database operation."""
pass
class InvalidFilterParameterException(Exception):
"""Raised when a query filter parameter is invalid."""
pass

View file

@ -6,7 +6,8 @@ from sqlalchemy.orm import Session
from todo.database.engine import get_db
from todo.schemas import todos as todoschema
from todo.crud import todos as todocrud
from todo.exceptions import NotFoundException, InvalidFilterParameterException
from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException
from todo.utils.exceptions import create_exception_dict as fmt
router = APIRouter(
@ -25,7 +26,7 @@ def create_todo(todo: todoschema.TodoItemCreate, user_id: int, db: Session = Dep
try:
return todocrud.create_todo(db=db, todo=todo, user_id=user_id)
except NotFoundException as e:
raise HTTPException(404, str(e))
raise HTTPException(404, fmt(str(e)))
@router.get("/{todo_id}", response_model=todoschema.TodoItem)
@ -33,7 +34,7 @@ def read_todo(todo_id: int, db: Session = Depends(get_db)):
try:
return todocrud.read_todo(db=db, todo_id=todo_id)
except NotFoundException as e:
raise HTTPException(404, str(e))
raise HTTPException(404, fmt(str(e)))
@router.get("/user/{user_id}", response_model=list[todoschema.TodoItem])
@ -41,9 +42,17 @@ def read_todos(user_id: int, skip: int = 0, limit: int = 100, db: Session = Depe
try:
return todocrud.read_todos_for_user(db=db, user_id=user_id, skip=skip, limit=limit)
except InvalidFilterParameterException as e:
raise HTTPException(400, str(e))
raise HTTPException(400, fmt(str(e)))
except NotFoundException as e:
raise HTTPException(404, str(e))
raise HTTPException(404, fmt(str(e)))
@router.get("/user/{user_id}/total", response_model=dict[str, int])
def read_todos_count(user_id: int, db: Session = Depends(get_db)):
try:
return {"total": todocrud.read_todos_count_for_user(db=db, user_id=user_id)}
except NotFoundException as e:
raise HTTPException(404, fmt(str(e)))
@router.patch("/{todo_id}", response_model=todoschema.TodoItem)
@ -51,7 +60,7 @@ def update_todo(todo_id: int, todo: todoschema.TodoItemUpdate, db: Session = Dep
try:
return todocrud.update_todo(db=db, todo=todo, todo_id=todo_id)
except NotFoundException as e:
raise HTTPException(404, str(e))
raise HTTPException(404, fmt(str(e)))
@router.delete("/{todo_id}", response_model=todoschema.TodoItem)
@ -59,4 +68,4 @@ def delete_todo(todo_id: int, db: Session = Depends(get_db)):
try:
return todocrud.delete_todo(db=db, todo_id=todo_id)
except NotFoundException as e:
raise HTTPException(404, str(e))
raise HTTPException(404, fmt(str(e)))

View file

@ -6,7 +6,8 @@ from sqlalchemy.orm import Session
from todo.database.engine import get_db
from todo.schemas import users as userschema
from todo.crud import users as usercrud
from todo.exceptions import NotFoundException
from todo.utils.exceptions import NotFoundException, InvalidFilterParameterException
from todo.utils.exceptions import create_exception_dict as fmt
router = APIRouter(
@ -35,12 +36,20 @@ def read_user(id: int, db: Session = Depends(get_db)):
try:
return usercrud.read_user(db=db, id=id)
except NotFoundException as e:
raise HTTPException(404, str(e))
raise HTTPException(404, fmt(str(e)))
@router.get("/", response_model=list[userschema.User])
def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
return usercrud.read_users(db=db, skip=skip, limit=limit)
try:
return usercrud.read_users(db=db, skip=skip, limit=limit)
except InvalidFilterParameterException as e:
raise HTTPException(400, fmt(str(e)))
@router.get("/total/", response_model=dict[str, int])
def read_users_count(db: Session = Depends(get_db)):
return {"total": usercrud.read_users_count(db=db)}
@router.patch("/{id}", response_model=userschema.User)
@ -48,7 +57,7 @@ def update_user(id: int, user: userschema.UserUpdate, db: Session = Depends(get_
try:
return usercrud.update_user(db=db, user=user, id=id)
except NotFoundException as e:
raise HTTPException(404, str(e))
raise HTTPException(404, fmt(str(e)))
@router.delete("/{id}", response_model=userschema.User)
@ -56,4 +65,4 @@ def delete_user(id: int, db: Session = Depends(get_db)):
try:
return usercrud.delete_user(db=db, id=id)
except NotFoundException as e:
raise HTTPException(404, str(e))
raise HTTPException(404, fmt(str(e)))

View file

View file

@ -0,0 +1,40 @@
"""This module is a collection of project-wide exceptions."""
class NotFoundException(Exception):
"""Raised when a resource was unexpectedly not found."""
pass
class DataIntegrityException(Exception):
"""Raised to prevent a semantically invalid database operation."""
pass
class InvalidFilterParameterException(Exception):
"""Raised when a query filter parameter is invalid."""
pass
def create_exception_dict(message: str):
"""Creates a list for returning API error messages to the client.
The list has the following format:
[
{
"msg": "Something went wrong!.",
},
]
This is useful to return consistent API error messages.
"""
if not isinstance(message, str):
raise TypeError(f"Expected a string but got '{type(message)}'.")
if not len(message):
message = "An unknown error occurred."
return [{"msg": message}]

View file

@ -0,0 +1,83 @@
"""This module contains utilities for creating fake database entries."""
from random import randint
from faker import Faker
from sqlalchemy.orm import Session
from todo.schemas.users import UserCreate, User
from todo.crud.users import create_user
from todo.schemas.todos import TodoItemCreate, TodoItem
from todo.crud.todos import create_todo
from todo.database.engine import get_db
def _get_faker() -> Faker:
"""Creates and returns a Faker object."""
return Faker()
def get_fake_user_details() -> UserCreate:
"""Returns a set of fake details for creating a new user."""
fk = _get_faker()
password = fk.password(length=randint(6, 16))
email = fk.profile(fields=['mail'])['mail']
first_name = fk.first_name()
last_name = fk.last_name()
return UserCreate(
email=email,
first_name=first_name,
last_name=last_name,
password=password,
password_confirmation=password,
)
def get_fake_todo_details() -> TodoItemCreate:
"""Returns a set of fake details for creating a new todo-item."""
fk = _get_faker()
title = fk.sentence(nb_words=randint(4, 6))
# Generate some sentences and concatenate them with a randomized delimiter
description_sentences = [fk.sentence(nb_words=randint(4, 6)) for i in range(randint(1, 5))]
if randint(0, 1):
delimiter = '\n- '
elif randint(0, 1):
delimiter = '\n'
else:
delimiter = ' '
description = delimiter.join(description_sentences)
return TodoItemCreate(
title=title,
description=description,
)
def create_fake_user(db: Session = next(get_db())) -> User:
"""Creates a fake user and saves them to the database."""
return create_user(db=db, user=get_fake_user_details())
def create_fake_todo(user_id: int, db: Session = next(get_db())) -> TodoItem:
"""Creates a fake todo-item for the specified user and saves it to the database."""
return create_todo(db=db, todo=get_fake_todo_details(), user_id=user_id)
def populate_database(num_users: int = 100, max_todos_per_user: int = 100) -> None:
"""Creates the specified number of users, each with between 0 and max_todos_per_user todo-items."""
db = next(get_db())
for i in range(num_users):
user = create_fake_user(db)
for j in range(randint(0, max_todos_per_user)):
create_fake_todo(user.id, db)

View file

@ -88,6 +88,6 @@ services:
ports:
- "8001:8081"
environment:
DATABASE_URL: "postgres://todo:todo@todo-db:5432/todo?sslmode=disable"
PGWEB_DATABASE_URL: "postgres://todo:todo@todo-db:5432/todo?sslmode=disable"
...

File diff suppressed because it is too large Load diff

View file

@ -10,13 +10,14 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-node": "^1.2.4",
"@sveltejs/kit": "^1.5.0",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.23",
"sass": "^1.62.1",
"svelte": "^3.54.0",
"svelte-check": "^3.0.1",
"svelte-material-ui": "^7.0.0-beta.8",
"tailwindcss": "^3.3.2",
"tslib": "^2.4.1",
"typescript": "^5.0.0",

View file

@ -1,9 +1,12 @@
/* This file imports all styles defined in other files. */
@import "./fonts.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Global defaults */
@layer base {
h1, h2, h3, h4, h5, h6 {
@apply font-bold text-primary;
@ -48,4 +51,42 @@
button:focus {
@apply ring-1 ring-neutral-400 ring-offset-1;
}
table {
@apply border-collapse;
}
th:first-of-type {
@apply rounded-tl-md;
}
th:last-of-type {
@apply rounded-tr-md;
}
tr:last-of-type td:first-of-type {
@apply rounded-bl-md;
}
tr:last-of-type td:last-of-type {
@apply rounded-br-md;
}
tr {
@apply hover:bg-secondary-300/50;
@apply border-y border-primary/50;
@apply bg-secondary-100/10;
}
tr:first-of-type, tr:last-of-type {
@apply border-y-0;
}
th {
@apply bg-primary font-semibold text-secondary-100;
}
caption, th, td {
@apply p-1;
}
@media screen(sm) {
caption, th, td {
@apply p-2 text-start;
}
}
}

View file

@ -1,27 +0,0 @@
<script lang='ts'>
function handleClick() {
promise = getUsers();
}
async function getUsers() {
const res = await fetch("/api/users/");
const json = await res.text();
if (res.ok) {
return json;
} else {
throw new Error(json);
}
}
let promise = getUsers();
</script>
<h1>TODOs</h1>
<button on:click={handleClick}>Click Me</button>
{#await promise}
<p>Waiting</p>
{:then users}
<p>{users}</p>
{:catch error}
<p style="color: red">{error}</p>
{/await}

View file

@ -0,0 +1,178 @@
<script lang='ts'>
import { onMount } from 'svelte';
const errorMessages : String[] = [];
let error = false;
let loading = true;
type User = {
id: number;
email: string;
first_name: string;
last_name: string;
created: Date;
updated: Date;
}
let currentItems: User[] = [];
let totalItemCount = 0;
let limit = 10;
let currentPage = 1;
let skip = 0;
$: totalPageCount = Math.ceil(totalItemCount / limit);
async function getUsersCount() {
const response = await fetch(`/api/users/total/`);
const responseJson = await response.json();
if (response.ok) {
if (responseJson) {
if (responseJson.hasOwnProperty('total')) {
if (typeof responseJson.total === 'number') {
return responseJson.total;
}
}
}
error = true;
} else {
// Try to parse the error messages
if (responseJson) {
if (responseJson.hasOwnProperty('detail')) {
if (Array.isArray(responseJson.detail)) {
for (let errorObj of responseJson.detail) {
if (errorObj.hasOwnProperty('msg')) {
if (typeof errorObj.msg === 'string') {
errorMessages.push(errorObj.msg);
}
}
}
}
}
}
if (errorMessages.length === 0) errorMessages.push("Error during API call.")
error = true;
}
}
async function getUsers(skip=0, limit=100) {
loading = true;
totalItemCount = await getUsersCount();
if (error) return;
const response = await fetch(`/api/users/?skip=${skip}&limit=${limit}`);
const responseJson = await response.json();
if (response.ok) {
currentItems = responseJson;
loading = false;
return responseJson;
} else {
// Try to parse the error messages
if (responseJson) {
if (responseJson.hasOwnProperty('detail')) {
if (Array.isArray(responseJson.detail)) {
for (let errorObj of responseJson.detail) {
if (errorObj.hasOwnProperty('msg')) {
if (typeof errorObj.msg === 'string') {
errorMessages.push(errorObj.msg);
}
}
}
}
}
}
loading = false;
error = true;
}
}
function handlePrevClick() {
currentPage -= 1;
skip -= limit;
getUsers(skip, limit);
}
function handleNextClick() {
currentPage += 1;
skip += limit;
getUsers(skip, limit);
}
function handleFirstClick() {
currentPage = 1;
skip = 0;
getUsers(skip, limit);
}
function handleLastClick() {
currentPage = totalPageCount;
skip = limit * (totalPageCount - 1);
getUsers(skip, limit);
}
onMount(async () => {
getUsers(skip, limit);
});
</script>
<div class="flex flex-col items-center">
<h1 class="text-center">Users</h1>
{#if loading}
<div class="min-h-screen flex flex-col items-center justify-center">
<p>Loading...</p>
</div>
{:else if error}
{#if errorMessages.length}
{#each errorMessages as errorMessage}
<p style="color: red">{errorMessage}</p>
{/each}
{:else}
<p style="color: red">An unknown error occurred.</p>
{/if}
{:else}
<div>
<table>
<caption>List of users</caption>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>First Name</th>
<th>Last Name</th>
<th>Created</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
{#each currentItems as user}
<tr>
<td>{user.id}</td>
<td>{user.email}</td>
<td>{user.first_name}</td>
<td>{user.last_name}</td>
<td>{new Date(user.created).toLocaleDateString()}</td>
<td>{new Date(user.updated).toLocaleDateString()}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class="flex items-center">
<select bind:value={limit} on:change={getUsers(skip, limit)}>
<option value="10">
10
</option>
<option value="25">
25
</option>
<option value="50">
50
</option>
</select>
<button on:click={handleFirstClick} disabled={currentPage === 1} class="disabled:opacity-10">first</button>
<button on:click={handlePrevClick} disabled={currentPage === 1} class="disabled:opacity-10">prev</button>
<button on:click={handleNextClick} disabled={currentPage === totalPageCount} class="disabled:opacity-10">next</button>
<button on:click={handleLastClick} disabled={currentPage === totalPageCount} class="disabled:opacity-10">last</button>
</div>
{/if}
</div>

View file

@ -0,0 +1,25 @@
@use 'sass:color';
@use '@material/theme/color-palette';
// Svelte Colors!
@use '@material/theme/index' as theme with (
$primary: #ff3e00,
$secondary: #676778,
$surface: #fff,
$background: #fff,
$error: color-palette.$red-900
);
html,
body {
background-color: theme.$surface;
color: theme.$on-surface;
}
a {
color: #40b3ff;
}
a:visited {
color: color.scale(#40b3ff, $lightness: -35%);
}

View file

@ -0,0 +1,25 @@
@use 'sass:color';
@use '@material/theme/color-palette';
// Svelte Colors! (Dark Theme)
@use '@material/theme/index' as theme with (
$primary: #ff3e00,
$secondary: color.scale(#676778, $whiteness: -10%),
$surface: color.adjust(color-palette.$grey-900, $blue: +4),
$background: #000,
$error: color-palette.$red-700
);
html,
body {
background-color: #000;
color: theme.$on-surface;
}
a {
color: #40b3ff;
}
a:visited {
color: color.scale(#40b3ff, $lightness: -35%);
}