Compare commits

11 Commits

18 changed files with 944 additions and 596 deletions

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
FROM node:18-bullseye-slim as frontend
WORKDIR /frontend/
COPY frontend/package.json frontend/package-lock.json /frontend/
RUN npm install
COPY frontend /frontend/
RUN npm run build
FROM python:3.10.1-slim-bullseye
RUN apt-get update && apt install dumb-init
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
EXPOSE 8080
RUN mkdir -p /app
WORKDIR /app
COPY hypercorn.toml /app/
CMD ["pdm", "run", "hypercorn", "--config", "hypercorn.toml", "backend.run:app"]
RUN python -m venv /ve
ENV PATH=/ve/bin:${PATH}
RUN pip install --no-cache-dir pdm
COPY backend/pdm.lock backend/pyproject.toml /app/
RUN pdm install --prod --no-lock --no-editable
COPY --from=frontend /frontend/build/*.js* /app/backend/static/
COPY --from=frontend /frontend/build/*.png /frontend/build/*.svg /app/backend/static/
COPY --from=frontend /frontend/build/index.html \
/app/backend/templates/
COPY --from=frontend /frontend/build/static/. /app/backend/static/
COPY backend/src/ /app/
USER nobody

View File

@@ -1,5 +1,9 @@
# Todo API
## To run
`docker-compose up -d`
## Frontend
### Development Workflow

View File

@@ -1,4 +1,3 @@
<<<<<<< HEAD
# Backend Technical Write-up
## Steps
@@ -16,16 +15,6 @@
`blueprint`: a collection of route handlers/API functionalities.
## API route trailing slashes
API paths should end with a slash i.e: `/sessions/` rather than `/session`.
This is because requests sent to `/sessions` will be redirected to `/sessions/` whereas `/sessions/` won't get redirected.
## Difference between database schema and database model
- A schema defines the structure of data within the database.
- A model is a class that can be represented as rows in the database, i.e ID row, age row as class member.
## Managing user's sessions (Authentication)
- Login should results in a cookie being set in the user's browser, which is being sent in every subsequent request.
@@ -35,11 +24,6 @@ This is because requests sent to `/sessions` will be redirected to `/sessions/`
## Idempotent routes
Idempotence is a property of a route where the final state is achieved no matter how many times the route is called, that is, calling the route once or 10 times has the same effect. This is a useful property as it means the route can be safely retried if the request fails. For RESTful and HTTP APIs, the routes using GET, PUT, and DELETE verbs are expected to be idempotent.
||||||| 3c78fe9
=======
# Backend Technical Write Up
## General Bits of Information
### SameSite setting
@@ -53,10 +37,6 @@ Pydantic is to validate the schema/the shape of our input/output (works with JSO
Class full of data. Meant to be used to serialize data into JSON objects.
### Quart specific terminologies
`blueprint`: a collection of route handlers/API functionalities.
### API route trailing slashes
API paths should end with a slash i.e: `/sessions/` rather than `/session`.
@@ -66,14 +46,3 @@ This is because requests sent to `/sessions` will be redirected to `/sessions/`
- A schema defines the structure of data within the database.
- A model is a class that can be represented as rows in the database, i.e ID row, age row as class member.
### Managing user's sessions (Authentication)
- Login should results in a cookie being set in the user's browser, which is being sent in every subsequent request.
The presence and value of this cookie are used to determine whether the member is logged in, and which member made the request.
- Logout results in the cookie being deleted.
### Idempotent routes
Idempotence is a property of a route where the final state is achieved no matter how many times the route is called, that is, calling the route once or 10 times has the same effect. This is a useful property as it means the route can be safely retried if the request fails. For RESTful and HTTP APIs, the routes using GET, PUT, and DELETE verbs are expected to be idempotent.
>>>>>>> master

1075
backend/pdm.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ dependencies = [
"bcrypt>=4.0.1",
"itsdangerous>=2.1.2",
"quart-rate-limiter>=0.7.0",
"pydantic[email]==1.10.11",
"pydantic[email]",
"quart-schema>=0.14.3",
"quart-db[postgresql]>=0.4.1",
"httpx>=0.23.1",

View File

@@ -7,4 +7,7 @@ blueprint = Blueprint("control", __name__)
@blueprint.get("/control/ping/")
@rate_exempt
async def ping() -> ResponseReturnValue:
"""Ping the server
Check if server is up and running.
"""
return {"ping": "pong"}

View File

@@ -0,0 +1,11 @@
from quart import Blueprint, ResponseReturnValue, render_template
from quart_rate_limiter import rate_exempt
blueprint = Blueprint("serving", __name__)
@blueprint.get("/")
@blueprint.get("/<path:path>")
@rate_exempt
async def index(path: str | None = None) -> ResponseReturnValue:
return await render_template("index.html")

View File

@@ -21,6 +21,7 @@ from quart_schema import QuartSchema, RequestSchemaValidationError
# Each blueprint is a logical collection of features in our web app
from backend.blueprints.control import blueprint as control_blueprint
from backend.blueprints.members import blueprint as members_blueprint
from backend.blueprints.serving import blueprint as serving_blueprint
from backend.blueprints.sessions import blueprint as sessions_blueprint
from backend.blueprints.todos import blueprint as todos_blueprint
@@ -48,6 +49,7 @@ app.register_blueprint(control_blueprint)
app.register_blueprint(sessions_blueprint)
app.register_blueprint(members_blueprint)
app.register_blueprint(todos_blueprint)
app.register_blueprint(serving_blueprint)
# Rate limiting

38
docker-compose.yaml Normal file
View File

@@ -0,0 +1,38 @@
version: "3"
services:
web-service:
build:
context: .
dockerfile: ./Dockerfile
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
networks:
- my_network
environment:
TODO_SECRET_KEY: "secret key"
TODO_QUART_DB_DATABASE_URL: postgres://postgres:postgres_password@postgres:5432/todo
TODO_QUART_DB_DATA_PATH: migrations/data.py
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: postgres_password
POSTGRES_DB: todo
POSTGRES_USER: postgres
networks:
- my_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 30s
timeout: 10s
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
networks:
my_network:

View File

@@ -79,5 +79,5 @@
"prettier": {
"trailingComma": "all"
},
"proxy": "http://localhost:5050"
"proxy": "http://127.0.0.1:5050"
}

View File

@@ -2,6 +2,7 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
import ScrollToTop from "./components/ScrollToTop";
import TopBar from "./components/TopBar";
import Register from "./pages/Register";
import ConfirmEmail from "./pages/ConfirmEmail";
import Login from "./pages/Login";
@@ -10,6 +11,10 @@ import ChangePassword from "./pages/ChangePassword";
import ForgottenPassword from "./pages/ForgottenPassword";
import ResetPassword from "./pages/ResetPassword";
import CreateTodo from "./pages/CreateTodo";
import EditTodo from "./pages/EditTodo";
import Todos from "./pages/Todos";
const Router = () => (
<BrowserRouter>
<ScrollToTop />
@@ -28,6 +33,30 @@ const Router = () => (
/>
<Route path="/forgotten-password/" element={<ForgottenPassword />} />
<Route path="/reset-password/:token/" element={<ResetPassword />} />
<Route
path="/"
element={
<RequireAuth>
<Todos />
</RequireAuth>
}
/>
<Route
path="/todos/new/"
element={
<RequireAuth>
<CreateTodo />
</RequireAuth>
}
/>
<Route
path="/todos/:id/"
element={
<RequireAuth>
<EditTodo />
</RequireAuth>
}
/>
</Routes>
</BrowserRouter>
);

View File

@@ -0,0 +1,61 @@
import Checkbox from "@mui/material/Checkbox";
import IconButton from "@mui/material/IconButton";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Skeleton from "@mui/material/Skeleton";
import DeleteIcon from "@mui/icons-material/Delete";
import { format } from "date-fns";
import { Link } from "react-router-dom";
import { Todo as TodoModel } from "../models";
import { useDeleteTodoMutation } from "../queries";
interface IProps {
todo?: TodoModel;
}
const Todo = ({ todo }: IProps) => {
const { mutateAsync: deleteTodo } = useDeleteTodoMutation();
let secondary;
if (todo === undefined) {
secondary = <Skeleton width="200px" />;
} else if (todo.due !== null) {
secondary = format(todo.due, "P");
}
return (
<ListItem
secondaryAction={
<IconButton
disabled={todo === undefined}
edge="end"
onClick={() => deleteTodo(todo?.id!)}
>
<DeleteIcon />
</IconButton>
}
>
<ListItemButton
component={Link}
disabled={todo === undefined}
to={`/todos/${todo?.id}/`}
>
<ListItemIcon>
<Checkbox
checked={todo?.complete ?? false}
disabled
disableRipple
edge="start"
tabIndex={-1}
/>
</ListItemIcon>
<ListItemText
primary={todo?.task ?? <Skeleton />}
secondary={secondary}
/>
</ListItemButton>
</ListItem>
);
};
export default Todo;

View File

@@ -0,0 +1,44 @@
import { Form, Formik } from "formik";
import * as yup from "yup";
import CheckboxField from "../components/CheckboxField";
import DateField from "../components/DateField";
import FormActions from "../components/FormActions";
import TextField from "../components/TextField";
import type { ITodoData } from "../queries";
interface IProps {
initialValues: ITodoData;
label: string;
onSubmit: (data: ITodoData) => Promise<any>;
}
const validationSchema = yup.object({
complete: yup.boolean(),
due: yup.date().nullable(),
task: yup.string().required("Required"),
});
const TodoForm = ({ initialValues, label, onSubmit }: IProps) => (
<Formik<ITodoData>
initialValues={initialValues}
onSubmit={onSubmit}
validationSchema={validationSchema}
>
{({ dirty, isSubmitting }) => (
<Form>
<TextField fullWidth label="Task" name="task" required />
<DateField fullWidth label="Due" name="due" />
<CheckboxField fullWidth label="Complete" name="complete" />
<FormActions
disabled={!dirty}
isSubmitting={isSubmitting}
label={label}
links={[{ label: "Back", to: "/" }]}
/>
</Form>
)}
</Formik>
);
export default TodoForm;

View File

@@ -0,0 +1,36 @@
import { useContext } from "react";
import { useNavigate } from "react-router-dom";
import TodoForm from "../components/TodoForm";
import Title from "../components/Title";
import type { ITodoData } from "../queries";
import { useCreateTodoMutation } from "../queries";
import { ToastContext } from "../ToastContext";
const CreateTodo = () => {
const navigate = useNavigate();
const { addToast } = useContext(ToastContext);
const { mutateAsync: createTodo } = useCreateTodoMutation();
const onSubmit = async (data: ITodoData) => {
try {
await createTodo(data);
navigate("/");
} catch {
addToast("Try Again", "error");
}
};
return (
<>
<Title title="Create a Todo" />
<TodoForm
initialValues={{ complete: false, due: null, task: "" }}
label="Create"
onSubmit={onSubmit}
/>
</>
);
};
export default CreateTodo;

View File

@@ -0,0 +1,52 @@
import Skeleton from "@mui/material/Skeleton";
import { useContext } from "react";
import { useNavigate, useParams } from "react-router";
import TodoForm from "../components/TodoForm";
import Title from "../components/Title";
import type { ITodoData } from "../queries";
import { useEditTodoMutation, useTodoQuery } from "../queries";
import { ToastContext } from "../ToastContext";
interface Iparams {
id: string;
}
const EditTodo = () => {
const navigate = useNavigate();
const params = useParams<keyof Iparams>() as Iparams;
const todoId = parseInt(params.id, 10);
const { addToast } = useContext(ToastContext);
const { data: todo } = useTodoQuery(todoId);
const { mutateAsync: editTodo } = useEditTodoMutation(todoId);
const onSubmit = async (data: ITodoData) => {
try {
await editTodo(data);
navigate("/");
} catch {
addToast("Try again", "error");
}
};
return (
<>
<Title title="Edit todo" />
{todo === undefined ? (
<Skeleton height="80px" />
) : (
<TodoForm
initialValues={{
complete: todo.complete,
due: todo.due,
task: todo.task,
}}
label="Edit"
onSubmit={onSubmit}
/>
)}
</>
);
};
export default EditTodo;

View File

@@ -0,0 +1,38 @@
import Fab from "@mui/material/Fab";
import List from "@mui/material/List";
import AddIcon from "@mui/icons-material/Add";
import { Link, Navigate } from "react-router-dom";
import Todo from "../components/Todo";
import { useTodosQuery } from "../queries";
const Todos = () => {
const { data: todos } = useTodosQuery();
if (todos?.length === 0) {
return <Navigate to="/todos/new/" />;
} else {
return (
<>
<List>
{todos !== undefined
? todos.map((todo) => <Todo key={todo.id} todo={todo} />)
: [1, 2, 3].map((id) => <Todo key={-id} />)}
</List>
<Fab
component={Link}
sx={{
bottom: (theme) => theme.spacing(2),
position: "fixed",
right: (theme) => theme.spacing(2),
}}
to="/todos/new/"
>
<AddIcon />
</Fab>
</>
);
}
};
export default Todos;

72
frontend/src/queries.ts Normal file
View File

@@ -0,0 +1,72 @@
import axios from "axios";
import { useQueryClient } from "@tanstack/react-query";
import { Todo } from "./models";
import { useMutation, useQuery } from "./query";
export const STALE_TIME = 1000 * 60 * 5; // 5 mins
export const useTodosQuery = () =>
useQuery<Todo[]>(
["todos"],
async () => {
const response = await axios.get("/todos/");
return response.data.todos.map((json: any) => new Todo(json));
},
{ staleTime: STALE_TIME },
);
export const useTodoQuery = (id: number) => {
const queryClient = useQueryClient();
return useQuery<Todo>(
["todos", id.toString()],
async () => {
const response = await axios.get(`/todos/${id}/`);
return new Todo(response.data);
},
{
initialData: () => {
return queryClient
.getQueryData<Todo[]>(["todos"])
?.filter((todo: Todo) => todo.id === id)[0];
},
staleTime: STALE_TIME,
},
);
};
export interface ITodoData {
complete: boolean;
due: Date | null;
task: string;
}
export const useCreateTodoMutation = () => {
const queryClient = useQueryClient();
return useMutation(
async (data: ITodoData) => await axios.post("/todos/", data),
{
onSuccess: () => queryClient.invalidateQueries(["todos"]),
},
);
};
export const useEditTodoMutation = (id: number) => {
const queryClient = useQueryClient();
return useMutation(
async (data: ITodoData) => await axios.put(`/todos/${id}/`, data),
{
onSuccess: () => queryClient.invalidateQueries(["todos"]),
},
);
};
export const useDeleteTodoMutation = () => {
const queryClient = useQueryClient();
return useMutation(
async (id: number) => await axios.delete(`/todos/${id}/`),
{
onSuccess: () => queryClient.invalidateQueries(["todos"]),
},
);
};

4
hypercorn.toml Normal file
View File

@@ -0,0 +1,4 @@
accesslog = "-"
access_log_format = "%(t)s %(h)s %(f)s - %(S)s '%(r)s' %(s)s %(b)s %(D)s"
bind = "0.0.0.0:8080"
errorlog = "-"