Add initial project files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 19:11:00 -07:00
parent bfab8f71fb
commit 72b23c18aa
32 changed files with 1870 additions and 0 deletions

10
backend/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

14
backend/app/config.py Normal file
View File

@@ -0,0 +1,14 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
secret_key: str
access_token_expire_minutes: int = 480
algorithm: str = "HS256"
class Config:
env_file = ".env"
settings = Settings()

20
backend/app/database.py Normal file
View File

@@ -0,0 +1,20 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.database_url, echo=False)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def init_db() -> None:
# Import models so their tables are registered on Base.metadata before create_all
from app.models import user, entry # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

View File

@@ -0,0 +1,35 @@
from typing import AsyncGenerator
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.utils.security import decode_token
bearer_scheme = HTTPBearer()
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
yield session
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
):
from app.models.user import User
token = credentials.credentials
user_id = decode_token(token)
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user

28
backend/app/main.py Normal file
View File

@@ -0,0 +1,28 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import init_db
from app.routers import auth, users, entries, public
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(title="Bourbonacci", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(entries.router)
app.include_router(public.router)

View File

View File

@@ -0,0 +1,28 @@
from datetime import datetime, date
from typing import Optional
from sqlalchemy import String, Text, Float, Date, DateTime, ForeignKey, Enum, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
import enum
from app.database import Base
class EntryType(str, enum.Enum):
add = "add"
remove = "remove"
class Entry(Base):
__tablename__ = "entries"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
entry_type: Mapped[EntryType] = mapped_column(Enum(EntryType), nullable=False)
date: Mapped[date] = mapped_column(Date, nullable=False)
bourbon_name: Mapped[Optional[str]] = mapped_column(String(255))
proof: Mapped[Optional[float]] = mapped_column(Float)
amount_shots: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
notes: Mapped[Optional[str]] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
user: Mapped["User"] = relationship("User", back_populates="entries")

View File

@@ -0,0 +1,19 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import String, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[Optional[str]] = mapped_column(String(100))
timezone: Mapped[str] = mapped_column(String(50), default="UTC")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
entries: Mapped[list["Entry"]] = relationship("Entry", back_populates="user", cascade="all, delete-orphan")

View File

View File

@@ -0,0 +1,39 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.dependencies import get_db
from app.models.user import User
from app.schemas.user import UserCreate, Token, LoginRequest
from app.utils.security import hash_password, verify_password, create_token
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/register", response_model=Token, status_code=status.HTTP_201_CREATED)
async def register(body: UserCreate, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == body.email))
if result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
user = User(
email=body.email,
password_hash=hash_password(body.password),
display_name=body.display_name or body.email.split("@")[0],
)
db.add(user)
await db.commit()
await db.refresh(user)
return Token(access_token=create_token(user.id))
@router.post("/login", response_model=Token)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == body.email))
user = result.scalar_one_or_none()
if not user or not verify_password(body.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
return Token(access_token=create_token(user.id))

View File

@@ -0,0 +1,95 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.entry import Entry, EntryType
from app.schemas.entry import EntryCreate, EntryResponse, BottleStats
router = APIRouter(prefix="/api/entries", tags=["entries"])
def _calc_stats(entries: list[Entry]) -> BottleStats:
adds = [e for e in entries if e.entry_type == EntryType.add]
removes = [e for e in entries if e.entry_type == EntryType.remove]
total_add_shots = sum(e.amount_shots for e in adds)
total_remove_shots = sum(e.amount_shots for e in removes)
current_total = total_add_shots - total_remove_shots
# Weighted average proof across all add entries
weighted_proof_sum = sum(e.proof * e.amount_shots for e in adds if e.proof is not None)
proof_shot_total = sum(e.amount_shots for e in adds if e.proof is not None)
estimated_proof = (weighted_proof_sum / proof_shot_total) if proof_shot_total > 0 else None
return BottleStats(
total_add_entries=len(adds),
current_total_shots=round(current_total, 2),
estimated_proof=round(estimated_proof, 1) if estimated_proof is not None else None,
)
@router.get("", response_model=list[EntryResponse])
async def list_entries(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Entry)
.where(Entry.user_id == current_user.id)
.order_by(Entry.date.desc(), Entry.created_at.desc())
)
return result.scalars().all()
@router.post("", response_model=EntryResponse, status_code=status.HTTP_201_CREATED)
async def create_entry(
body: EntryCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if body.entry_type == EntryType.add and not body.bourbon_name:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="bourbon_name is required for add entries")
entry = Entry(
user_id=current_user.id,
entry_type=body.entry_type,
date=body.date,
bourbon_name=body.bourbon_name,
proof=body.proof,
amount_shots=body.amount_shots,
notes=body.notes,
)
async with db.begin():
db.add(entry)
await db.refresh(entry)
return entry
@router.delete("/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_entry(
entry_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Entry).where(Entry.id == entry_id, Entry.user_id == current_user.id)
)
entry = result.scalar_one_or_none()
if not entry:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Entry not found")
async with db.begin():
await db.delete(entry)
@router.get("/stats", response_model=BottleStats)
async def get_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Entry).where(Entry.user_id == current_user.id))
entries = result.scalars().all()
return _calc_stats(entries)

View File

@@ -0,0 +1,41 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.dependencies import get_db
from app.models.user import User
from app.models.entry import Entry, EntryType
from app.schemas.entry import PublicUserStats
router = APIRouter(prefix="/api/public", tags=["public"])
@router.get("/stats", response_model=list[PublicUserStats])
async def public_stats(db: AsyncSession = Depends(get_db)):
users_result = await db.execute(select(User))
users = users_result.scalars().all()
stats: list[PublicUserStats] = []
for user in users:
entries_result = await db.execute(select(Entry).where(Entry.user_id == user.id))
entries = entries_result.scalars().all()
adds = [e for e in entries if e.entry_type == EntryType.add]
removes = [e for e in entries if e.entry_type == EntryType.remove]
total_add_shots = sum(e.amount_shots for e in adds)
total_remove_shots = sum(e.amount_shots for e in removes)
current_total = total_add_shots - total_remove_shots
weighted_proof_sum = sum(e.proof * e.amount_shots for e in adds if e.proof is not None)
proof_shot_total = sum(e.amount_shots for e in adds if e.proof is not None)
estimated_proof = round(weighted_proof_sum / proof_shot_total, 1) if proof_shot_total > 0 else None
stats.append(PublicUserStats(
display_name=user.display_name or user.email.split("@")[0],
total_add_entries=len(adds),
current_total_shots=round(current_total, 2),
estimated_proof=estimated_proof,
))
return stats

View File

@@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.schemas.user import UserResponse, UserUpdate, PasswordChange
from app.utils.security import verify_password, hash_password
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("/me", response_model=UserResponse)
async def get_me(current_user: User = Depends(get_current_user)):
return current_user
@router.put("/me", response_model=UserResponse)
async def update_me(
body: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if body.display_name is not None:
current_user.display_name = body.display_name
if body.timezone is not None:
current_user.timezone = body.timezone
async with db.begin():
db.add(current_user)
await db.refresh(current_user)
return current_user
@router.put("/me/password", status_code=status.HTTP_204_NO_CONTENT)
async def change_password(
body: PasswordChange,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not verify_password(body.current_password, current_user.password_hash):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Current password is incorrect")
current_user.password_hash = hash_password(body.new_password)
async with db.begin():
db.add(current_user)

View File

View File

@@ -0,0 +1,40 @@
from datetime import datetime, date
from typing import Optional
from pydantic import BaseModel
from app.models.entry import EntryType
class EntryCreate(BaseModel):
entry_type: EntryType
date: date
bourbon_name: Optional[str] = None
proof: Optional[float] = None
amount_shots: float = 1.0
notes: Optional[str] = None
class EntryResponse(BaseModel):
id: int
entry_type: EntryType
date: date
bourbon_name: Optional[str]
proof: Optional[float]
amount_shots: float
notes: Optional[str]
created_at: datetime
model_config = {"from_attributes": True}
class BottleStats(BaseModel):
total_add_entries: int
current_total_shots: float
estimated_proof: Optional[float]
class PublicUserStats(BaseModel):
display_name: str
total_add_entries: int
current_total_shots: float
estimated_proof: Optional[float]

View File

@@ -0,0 +1,39 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
email: EmailStr
password: str
display_name: Optional[str] = None
class UserUpdate(BaseModel):
display_name: Optional[str] = None
timezone: Optional[str] = None
class PasswordChange(BaseModel):
current_password: str
new_password: str
class UserResponse(BaseModel):
id: int
email: str
display_name: Optional[str]
timezone: str
created_at: datetime
model_config = {"from_attributes": True}
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class LoginRequest(BaseModel):
email: EmailStr
password: str

View File

View File

@@ -0,0 +1,34 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_token(user_id: int) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": str(user_id), "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def decode_token(token: str) -> Optional[int]:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
user_id = payload.get("sub")
if user_id is None:
return None
return int(user_id)
except JWTError:
return None

9
backend/requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
sqlalchemy[asyncio]==2.0.36
aiomysql==0.2.0
pydantic-settings==2.7.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.20
pytz==2024.2