Skip to content
Merged
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
141 changes: 122 additions & 19 deletions backend/app/api/routes.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,34 @@
import datetime as dt
import secrets

from collections.abc import Sequence
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, HTTPException, Query, Response, status
from pydantic import EmailStr, NonNegativeInt
from fastapi import APIRouter, Cookie, HTTPException, Query, Response, status
from pwdlib import PasswordHash
from pydantic import EmailStr, NonNegativeInt, SecretStr
from sqlalchemy.dialects.postgresql import insert
from sqlmodel import select

from app.dependencies import AuthDep, SessionDep
from app.dependencies import AuthDep, SessionDep, UserSessionDep
from app.enums import Category
from app.models import Cart, CartItem, NewCart, NewCartItem, NewUser, Product, User
from app.models import (
Cart,
CartItem,
NewCart,
NewCartItem,
NewUser,
NewUserSession,
Product,
User,
UserSession,
)

router = APIRouter()

hasher = PasswordHash.recommended()


@router.get("/api/products")
async def get_products(
Expand Down Expand Up @@ -40,25 +56,32 @@ async def get_product(id: str, session: SessionDep) -> Product:
async def create_user(
user: NewUser, session: SessionDep, response: Response, _: AuthDep
):
if session.get(User, user.username) is not None:
raise HTTPException(status_code=400, detail="User already exists")

user.password = hasher.hash(str(user.password))

session.add(User(**user.model_dump()))
session.commit()

response.headers["Location"] = f"/users/{user.id}"
response.headers["Location"] = f"/users/account"


@router.get("/api/users/{id}")
async def get_user(id: str, session: SessionDep) -> User:
user = session.get(User, id)
@router.get("/api/users/{username}")
async def get_user(username: str, session: SessionDep, _: AuthDep) -> User:
user = session.get(User, username)

if user is None:
raise HTTPException(status_code=404, detail="User not found")

return user


@router.patch("/api/users/{id}")
async def update_user_email(id: str, email: EmailStr, session: SessionDep, _: AuthDep):
user = session.get(User, id)
@router.patch("/api/users/{username}")
async def update_user_email(
username: str, email: EmailStr, session: SessionDep, _: AuthDep
):
user = session.get(User, username)

if user is None:
raise HTTPException(status_code=404, detail="User not found")
Expand All @@ -68,9 +91,9 @@ async def update_user_email(id: str, email: EmailStr, session: SessionDep, _: Au
session.commit()


@router.delete("/api/users/{id}/", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(id: str, session: SessionDep, _: AuthDep):
user = session.get(User, id)
@router.delete("/api/users/{username}/", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(username: str, session: SessionDep, _: AuthDep):
user = session.get(User, username)

if user is None:
raise HTTPException(status_code=404, detail="Cart not found")
Expand All @@ -79,10 +102,56 @@ async def delete_user(id: str, session: SessionDep, _: AuthDep):
session.commit()


@router.post("/api/login")
async def create_user_session(
username: str, password: SecretStr, session: SessionDep, response: Response
):
user = session.get(User, username)

if user is None or hasher.verify(password.get_secret_value(), user.password):
raise HTTPException(status_code=401, detail="Invalid credentials")

user_session = NewUserSession(
id=secrets.token_urlsafe(),
username=username,
expires_at=dt.datetime.now() + dt.timedelta(days=1),
)

session.add(UserSession(**user_session.model_dump()))
session.commit()

response.set_cookie(
key="session_id",
value=user_session.id,
httponly=True,
samesite="strict",
)


@router.post("/api/logout", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user_session(
session: SessionDep,
user_session_id: Annotated[str | None, Cookie()] = None,
):
if user_session_id is not None:
user_session = session.get(UserSession, user_session_id)

if user_session is not None:
session.delete(user_session)
session.commit()


@router.post("/api/carts", status_code=status.HTTP_201_CREATED)
async def create_cart(
cart: NewCart, response: Response, session: SessionDep, _: AuthDep
id: UUID,
user_id: str | None,
response: Response,
user_session: UserSessionDep,
session: SessionDep,
_: AuthDep,
):
cart = NewCart(id=id, user_id=user_id, session_id=user_session.id)

session.add(Cart(**cart.model_dump()))
session.commit()

Expand Down Expand Up @@ -114,10 +183,16 @@ async def delete_cart(id: UUID, session: SessionDep, _: AuthDep):
async def create_cart_item(
cart_id: UUID,
item: NewCartItem,
user_session: UserSessionDep,
session: SessionDep,
response: Response,
_: AuthDep,
):
cart = session.get(Cart, user_session.id)

if cart is None or cart.session_id != user_session.id:
raise HTTPException(status_code=404, detail="Cart not found")

query = (
insert(CartItem)
.values(cart_id=cart_id, product_id=item.product_id, quantity=item.quantity)
Expand All @@ -135,11 +210,12 @@ async def create_cart_item(

@router.get("/api/carts/{id}/items")
async def get_cart_items(
id: UUID,
session: SessionDep,
id: UUID, user_session: UserSessionDep, session: SessionDep, _: AuthDep
) -> Sequence[CartItem]:

if session.get(Cart, id) is None:
cart = session.get(Cart, user_session.id)

if cart is None or cart.session_id != user_session.id:
raise HTTPException(status_code=404, detail="Cart not found")

query = select(CartItem).where(CartItem.cart_id == id)
Expand All @@ -151,8 +227,16 @@ async def get_cart_items(
async def get_cart_item(
id: UUID,
product_id: str,
user_session: UserSessionDep,
session: SessionDep,
_: AuthDep,
) -> CartItem:

cart = session.get(Cart, user_session.id)

if cart is None or cart.session_id != user_session.id:
raise HTTPException(status_code=404, detail="Cart not found")

cart_item = session.get(CartItem, (id, product_id))

if cart_item is None:
Expand All @@ -165,10 +249,17 @@ async def get_cart_item(
async def update_cart_item_quantity(
id: UUID,
product_id: str,
user_session: UserSessionDep,
quantity: NonNegativeInt,
session: SessionDep,
_: AuthDep,
):

cart = session.get(Cart, user_session.id)

if cart is None or cart.session_id != user_session.id:
raise HTTPException(status_code=404, detail="Cart not found")

cart_item = session.get(CartItem, (id, product_id))

if cart_item is None:
Expand All @@ -182,7 +273,19 @@ async def update_cart_item_quantity(
@router.delete(
"/api/carts/{id}/items/{product_id}", status_code=status.HTTP_204_NO_CONTENT
)
async def delete_cart_item(id: UUID, product_id: str, session: SessionDep, _: AuthDep):
async def delete_cart_item(
id: UUID,
product_id: str,
user_session: UserSessionDep,
session: SessionDep,
_: AuthDep,
):

cart = session.get(Cart, user_session.id)

if cart is None or cart.session_id != user_session.id:
raise HTTPException(status_code=404, detail="Cart not found")

cart_item = session.get(CartItem, (id, product_id))

if cart_item is None:
Expand Down
47 changes: 45 additions & 2 deletions backend/app/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import datetime as dt
import os
import secrets
import sys
from typing import Annotated

from fastapi import Depends, HTTPException
from fastapi import Cookie, Depends, HTTPException, Response
from fastapi.security import APIKeyHeader
from sqlmodel import Session, create_engine
from sqlmodel import Session, create_engine, select

from app.models import User, UserSession

if (POSTGRES_URL := os.getenv("POSTGRES_URL")) is None:
sys.exit("The POSTGRES_URL env variable must be set.")
Expand All @@ -28,3 +31,43 @@ def auth_api_key(api_key: Annotated[str, Depends(APIKeyHeader(name="api-key"))])

SessionDep = Annotated[Session, Depends(get_session)]
AuthDep = Annotated[str, Depends(auth_api_key)]


def get_current_session(
session: SessionDep,
response: Response,
user_session_id: Annotated[str | None, Cookie()] = None,
) -> UserSession:
if user_session_id is None:
user_session = UserSession(
id=secrets.token_urlsafe(),
username=None,
expires_at=dt.datetime.now() + dt.timedelta(days=1),
)

session.add(user_session)
session.commit()

response.set_cookie(
key="session_id",
value=user_session.id,
httponly=True,
samesite="strict",
)

return user_session

user_session = session.get(UserSession, user_session_id)

if user_session is None:
raise HTTPException(status_code=401, detail="Invalid session")

if user_session.expires_at > dt.datetime.now():
session.delete(user_session)

raise HTTPException(status_code=401, detail="Session expired")

return user_session


UserSessionDep = Annotated[UserSession, Depends(get_current_session)]
7 changes: 6 additions & 1 deletion backend/app/html/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from uuid import UUID

from fastapi import APIRouter, HTTPException, Query, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from jinja2 import Environment, FileSystemLoader
from pydantic import EmailStr, NonNegativeInt
Expand Down Expand Up @@ -47,3 +47,8 @@ async def get_products(
return templates.TemplateResponse(
request, "products.html", context={"products": products}
)


@router.get("/html/login", response_class=HTMLResponse)
async def login(request: Request):
return templates.TemplateResponse(request, "login.html")
19 changes: 11 additions & 8 deletions backend/app/html/templates/header.html
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
<header class="sticky top-0 left-0 z-50 w-full bg-white/70 py-4 backdrop-blur">
<header
id="site-header"
class="sticky top-0 left-0 z-50 w-full border-b border-black py-4 duration-400"
>
<div
class="flex items-center justify-between px-6 text-sm tracking-wide text-black/80"
class="flex items-center justify-between px-6 text-sm tracking-wide text-black"
>
<nav class="flex items-center gap-8">
<a href="#" class="transition hover:text-white">shop</a>
<a href="#" class="transition hover:text-white">account</a>
<a href="#" class="transition hover:text-white">about</a>
<a href="#">shop</a>
<a href="/html/login">sign in</a>
<a href="#">about</a>
</nav>
<div class="absolute left-1/2 -translate-x-1/2">
<a href="/" class="text-lg font-semibold tracking-[0.25em] text-black">
PATCHWORK STORE
</a>
</div>
<div class="flex items-center gap-5">
<button class="transition hover:text-white">
<button>
<svg
class="h-5 w-5"
fill="none"
Expand All @@ -24,7 +27,7 @@
<path d="M20 20L17 17" stroke-width="1.5"></path>
</svg>
</button>
<button class="transition hover:text-white">
<button>
<svg
class="h-5 w-5"
fill="none"
Expand All @@ -35,7 +38,7 @@
<path d="M4 20c1.5-4 14.5-4 16 0" stroke-width="1.5"></path>
</svg>
</button>
<button class="transition hover:text-white">
<button>
<svg
class="h-5 w-5"
fill="none"
Expand Down
1 change: 1 addition & 0 deletions backend/app/html/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@
<body>
{% include "header.html" %}
<main>{% block content %}{% endblock %}</main>
<script src="/static/js/header-scroll.js"></script>
</body>
</html>
Loading