weeee💃

This commit is contained in:
mohamad 2025-03-27 08:13:54 +01:00
commit 240e54eec4
145 changed files with 14006 additions and 0 deletions

8
app/.env Normal file
View File

@ -0,0 +1,8 @@
POSTGRES_SERVER=ep-little-term-a2a5cvsf-pooler.eu-central-1.aws.neon.tech
POSTGRES_USER=neondb_owner
POSTGRES_PASSWORD=npg_gB8ivHCr7SeR
POSTGRES_DB=dooey
SQLALCHEMY_DATABASE_URI=postgresql+asyncpg://neondb_owner:npg_gB8ivHCr7SeR@ep-little-term-a2a5cvsf-pooler.eu-central-1.aws.neon.tech/dooey?sslmode=require
SECRET_KEY=your-secret-key-here
ACCESS_TOKEN_EXPIRE_MINUTES=1440
BACKEND_CORS_ORIGINS=["http://localhost:5174", "http://localhost:5175", "http://localhost:5173"]

0
app/.gitignore vendored Normal file
View File

22
app/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
# Dockerfile
FROM python:3.11-slim
WORKDIR /app/
# Install Poetry
RUN pip install poetry
# Copy poetry configuration files
COPY pyproject.toml poetry.lock* /app/
# Configure poetry to not use a virtual environment
RUN poetry config virtualenvs.create false
# Install dependencies
RUN poetry install --no-dev
# Copy application code
COPY . /app/
# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
app/README.md Normal file
View File

38
app/alembic.ini Normal file
View File

@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost/household
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

Binary file not shown.

85
app/alembic/env.py Normal file
View File

@ -0,0 +1,85 @@
# alembic/env.py
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.db.base import Base
from app.models import user, house, shopping_list, expense, chore
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

0
app/app/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,89 @@
# app/api/auth/router.py
from datetime import timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.core.config import settings
from app.core.security import create_access_token, get_password_hash, verify_password
from app.db.session import get_db
from app.models.user import User
from app.schemas.user import User as UserSchema
from app.schemas.user import UserCreate
from app.core.logger import logger # Import the logger
router = APIRouter()
@router.post("/register", response_model=UserSchema)
async def register(
user_in: UserCreate,
db: Annotated[AsyncSession, Depends(get_db)],
):
# Check if user exists
result = await db.execute(select(User).where(User.email == user_in.email))
user = result.scalars().first()
if user:
logger.warning(f"Registration attempt with existing email: {user_in.email}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
# Create new user
db_user = User(
email=user_in.email,
password_hash=get_password_hash(user_in.password),
full_name=user_in.full_name,
)
db.add(db_user)
await db.commit()
await db.refresh(db_user)
logger.info(f"New user registered: {db_user.email} (ID: {db_user.id})")
return db_user
@router.post("/login")
async def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Annotated[AsyncSession, Depends(get_db)],
):
# Authenticate user
result = await db.execute(select(User).where(User.email == form_data.username))
user = result.scalars().first()
if not user or not verify_password(form_data.password, user.password_hash):
logger.warning(f"Failed login attempt for user: {form_data.username}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Create access token
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
subject=str(user.id), expires_delta=access_token_expires
)
logger.info(f"User logged in: {user.email} (ID: {user.id})")
return {
"access_token": access_token,
"token_type": "bearer",
}
@router.post("/refresh")
async def refresh_token():
# This would be implemented with refresh tokens
# For simplicity, we're not implementing this now
pass
@router.post("/logout")
async def logout():
# This would be implemented with token blacklisting
# For simplicity, we're not implementing this now
return {"detail": "Successfully logged out"}

View File

Binary file not shown.

View File

@ -0,0 +1,260 @@
# app/api/chores/router.py
from typing import Annotated, List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import check_house_membership, get_current_user
from app.db.session import get_db
from app.models.chore import Chore
from app.models.user import User
from app.schemas.chore import (
Chore as ChoreSchema,
ChoreAssign,
ChoreComplete,
ChoreCreate,
ChoreUpdate,
)
router = APIRouter()
@router.get("/{house_id}/chores", response_model=List[ChoreSchema])
async def get_chores(
house_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Get all chores for a house."""
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Get all chores for the house
result = await db.execute(select(Chore).where(Chore.house_id == house_id))
chores = result.scalars().all()
return chores
@router.post(
"/{house_id}/chores", response_model=ChoreSchema, status_code=status.HTTP_201_CREATED
)
async def create_chore(
house_id: UUID,
chore_in: ChoreCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Create new chore for a house."""
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# If assigned_to is provided, check if the user is a member of the house
if chore_in.assigned_to:
is_assignee_member = await check_house_membership(
db, str(chore_in.assigned_to), str(house_id)
)
if not is_assignee_member:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Assigned user is not a member of this house",
)
# Create the chore
db_chore = Chore(
house_id=house_id,
**chore_in.model_dump(),
)
db.add(db_chore)
await db.commit()
await db.refresh(db_chore)
return db_chore
@router.put("/{house_id}/chores/{chore_id}", response_model=ChoreSchema)
async def update_chore(
house_id: UUID,
chore_id: UUID,
chore_in: ChoreUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Update chore."""
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Get the chore
result = await db.execute(
select(Chore).where(
Chore.id == chore_id,
Chore.house_id == house_id,
)
)
chore = result.scalars().first()
if not chore:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Chore not found",
)
# If assigned_to is provided, check if the user is a member of the house
if chore_in.assigned_to:
is_assignee_member = await check_house_membership(
db, str(chore_in.assigned_to), str(house_id)
)
if not is_assignee_member:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Assigned user is not a member of this house",
)
# Update chore fields
update_data = chore_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(chore, field, value)
await db.commit()
await db.refresh(chore)
return chore
@router.delete("/{house_id}/chores/{chore_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_chore(
house_id: UUID,
chore_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Delete chore."""
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Get the chore
result = await db.execute(
select(Chore).where(
Chore.id == chore_id,
Chore.house_id == house_id,
)
)
chore = result.scalars().first()
if not chore:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Chore not found",
)
# Delete the chore
await db.delete(chore)
await db.commit()
return None
@router.patch("/{house_id}/chores/{chore_id}/assign", response_model=ChoreSchema)
async def assign_chore(
house_id: UUID,
chore_id: UUID,
assign_data: ChoreAssign,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Assign chore to user."""
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Get the chore
result = await db.execute(
select(Chore).where(
Chore.id == chore_id,
Chore.house_id == house_id,
)
)
chore = result.scalars().first()
if not chore:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Chore not found",
)
# Check if the assigned user is a member of the house
is_assignee_member = await check_house_membership(
db, str(assign_data.assigned_to), str(house_id)
)
if not is_assignee_member:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Assigned user is not a member of this house",
)
# Assign the chore
chore.assigned_to = assign_data.assigned_to
await db.commit()
await db.refresh(chore)
return chore
@router.patch("/{house_id}/chores/{chore_id}/complete", response_model=ChoreSchema)
async def complete_chore(
house_id: UUID,
chore_id: UUID,
complete_data: ChoreComplete,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Mark chore as complete/incomplete."""
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Get the chore
result = await db.execute(
select(Chore).where(
Chore.id == chore_id,
Chore.house_id == house_id,
)
)
chore = result.scalars().first()
if not chore:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Chore not found",
)
# Update completion status
chore.is_completed = complete_data.is_completed
await db.commit()
await db.refresh(chore)
return chore

View File

View File

@ -0,0 +1,320 @@
# app/api/expenses/router.py
from decimal import Decimal
from typing import Annotated, Dict, List
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import check_house_membership, get_current_user
from app.db.session import get_db
from app.models.expense import Expense, ExpenseSplit
from app.models.house import HouseMember
from app.models.shopping_list import ShoppingList
from app.models.user import User
from app.schemas.expense import (
Expense as ExpenseSchema,
ExpenseCreate,
ExpenseSummary,
ExpenseUpdate,
)
router = APIRouter()
@router.get("/{house_id}/lists/{list_id}/expenses", response_model=List[ExpenseSchema])
async def get_expenses(
house_id: UUID,
list_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Get all expenses for a list."""
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Check if the list belongs to the house
result = await db.execute(
select(ShoppingList).where(
ShoppingList.id == list_id,
ShoppingList.house_id == house_id,
)
)
shopping_list = result.scalars().first()
if not shopping_list:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Shopping list not found",
)
# Get all expenses for the list
result = await db.execute(select(Expense).where(Expense.list_id == list_id))
expenses = result.scalars().all()
return expenses
@router.post(
"/{house_id}/lists/{list_id}/expenses",
response_model=ExpenseSchema,
status_code=status.HTTP_201_CREATED,
)
async def create_expense(
house_id: UUID,
list_id: UUID,
expense_in: ExpenseCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Create expense."""
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Check if the list belongs to the house
result = await db.execute(
select(ShoppingList).where(
ShoppingList.id == list_id,
ShoppingList.house_id == house_id,
)
)
shopping_list = result.scalars().first()
if not shopping_list:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Shopping list not found",
)
# Check if payer is a member of the house
is_payer_member = await check_house_membership(
db, str(expense_in.payer_id), str(house_id)
)
if not is_payer_member:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Payer is not a member of this house",
)
# Validate that the sum of splits equals the total amount
splits_total = sum(split.amount for split in expense_in.splits)
if splits_total != expense_in.amount:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Sum of splits must equal the total expense amount",
)
# Create the expense
db_expense = Expense(
list_id=list_id,
payer_id=expense_in.payer_id,
amount=expense_in.amount,
description=expense_in.description,
date=expense_in.date,
)
db.add(db_expense)
await db.flush()
# Create the splits
for split in expense_in.splits:
# Check if user is a member of the house
is_user_member = await check_house_membership(
db, str(split.user_id), str(house_id)
)
if not is_user_member:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"User {split.user_id} is not a member of this house",
)
db_split = ExpenseSplit(
expense_id=db_expense.id,
user_id=split.user_id,
amount=split.amount,
)
db.add(db_split)
await db.commit()
await db.refresh(db_expense)
return db_expense
@router.put("/{house_id}/lists/{list_id}/expenses/{expense_id}", response_model=ExpenseSchema)
async def update_expense(
house_id: UUID,
list_id: UUID,
expense_id: UUID,
expense_in: ExpenseUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Update expense."""
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Check if the list belongs to the house
result = await db.execute(
select(ShoppingList).where(
ShoppingList.id == list_id,
ShoppingList.house_id == house_id,
)
)
shopping_list = result.scalars().first()
if not shopping_list:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Shopping list not found",
)
# Get the expense
result = await db.execute(
select(Expense).where(
Expense.id == expense_id,
Expense.list_id == list_id,
)
)
expense = result.scalars().first()
if not expense:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Expense not found",
)
# Update expense fields
update_data = expense_in.model_dump(exclude_unset=True)
# Handle splits separately
splits = update_data.pop("splits", None)
# Update basic expense fields
for field, value in update_data.items():
if field == "payer_id" and value:
# Check if new payer is a member of the house
is_payer_member = await check_house_membership(
db, str(value), str(house_id)
)
if not is_payer_member:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Payer is not a member of this house",
)
setattr(expense, field, value)
# Handle splits if provided
if splits is not None:
# Validate that the sum of splits equals the total amount
splits_total = sum(split.amount for split in splits)
if splits_total != expense.amount:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Sum of splits must equal the total expense amount",
)
# Delete existing splits
await db.execute(
"DELETE FROM expense_splits WHERE expense_id = :expense_id",
{"expense_id": expense_id},
)
# Create new splits
for split in splits:
# Check if user is a member of the house
is_user_member = await check_house_membership(
db, str(split.user_id), str(house_id)
)
if not is_user_member:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"User {split.user_id} is not a member of this house",
)
db_split = ExpenseSplit(
expense_id=expense_id,
user_id=split.user_id,
amount=split.amount,
)
db.add(db_split)
await db.commit()
await db.refresh(expense)
return expense
@router.get("/{house_id}/lists/{list_id}/expenses/summary", response_model=ExpenseSummary)
async def get_expense_summary(
house_id: UUID,
list_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Get expense summary and splits."""
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Check if the list belongs to the house
result = await db.execute(
select(ShoppingList).where(
ShoppingList.id == list_id,
ShoppingList.house_id == house_id,
)
)
shopping_list = result.scalars().first()
if not shopping_list:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Shopping list not found",
)
# Get all expenses for the list
result = await db.execute(select(Expense).where(Expense.list_id == list_id))
expenses = result.scalars().all()
# Get all house members
result = await db.execute(
select(HouseMember).where(HouseMember.house_id == house_id)
)
members = result.scalars().all()
# Calculate total amount
total_amount = sum(expense.amount for expense in expenses)
# Calculate user balances
user_balances: Dict[UUID, Decimal] = {member.user_id: Decimal("0") for member in members}
# For each expense, add the amount paid to the payer's balance
# and subtract the split amounts from each user's balance
for expense in expenses:
# Add the full amount to the payer's balance (they paid this amount)
user_balances[expense.payer_id] += expense.amount
# Subtract each user's split from their balance (they owe this amount)
for split in expense.splits:
user_balances[split.user_id] -= split.amount
return ExpenseSummary(
total_amount=total_amount,
user_balances={user_id: balance for user_id, balance in user_balances.items()},
)

View File

Binary file not shown.

View File

@ -0,0 +1,478 @@
from datetime import datetime, timedelta
from sqlalchemy.future import select
from app.models.invite import HouseInvite
from app.schemas.invite import HouseInvite as HouseInviteSchema, HouseInviteCreate
from app.services.invite_service import generate_invite_code
from typing import Annotated, List
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import check_house_membership, get_current_user
from app.db.session import get_db
from app.models.house import House, HouseMember
from app.models.user import User
from app.schemas.house import (
House as HouseSchema,
HouseCreate,
HouseMember as HouseMemberSchema,
HouseMemberCreate,
HouseMemberUpdate,
HouseUpdate,
HouseMemberResponse,
)
router = APIRouter()
@router.get("/", response_model=List[HouseSchema])
async def get_houses(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Get all houses for the authenticated user."""
query = (
select(House)
.join(HouseMember, House.id == HouseMember.house_id)
.where(HouseMember.user_id == current_user.id)
)
result = await db.execute(query)
houses = result.scalars().all()
return houses
@router.post("/", response_model=HouseSchema, status_code=status.HTTP_201_CREATED)
async def create_house(
house_in: HouseCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Create a new house."""
# Create the house
db_house = House(**house_in.model_dump())
db.add(db_house)
await db.flush()
# Add the current user as an admin
db_member = HouseMember(
house_id=db_house.id,
user_id=current_user.id,
role="admin",
)
db.add(db_member)
await db.commit()
await db.refresh(db_house)
return db_house
@router.get("/{house_id}", response_model=HouseSchema)
async def get_house(
house_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Get house details."""
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Get the house
result = await db.execute(select(House).where(House.id == house_id))
house = result.scalars().first()
if not house:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="House not found",
)
return house
@router.put("/{house_id}", response_model=HouseSchema)
async def update_house(
house_id: UUID,
house_in: HouseUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Update house details."""
# Check if user is an admin of the house
is_admin = await check_house_membership(
db, str(current_user.id), str(house_id), required_role="admin"
)
if not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Must be an admin to update house details",
)
# Get the house
result = await db.execute(select(House).where(House.id == house_id))
house = result.scalars().first()
if not house:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="House not found",
)
# Update house fields
update_data = house_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(house, field, value)
await db.commit()
await db.refresh(house)
return house
@router.delete("/{house_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_house(
house_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Delete house."""
# Check if user is an admin of the house
is_admin = await check_house_membership(
db, str(current_user.id), str(house_id), required_role="admin"
)
if not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Must be an admin to delete house",
)
# Get the house
result = await db.execute(select(House).where(House.id == house_id))
house = result.scalars().first()
if not house:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="House not found",
)
# Delete the house
await db.delete(house)
await db.commit()
return None
# House Members endpoints
@router.get("/{house_id}/members", response_model=List[HouseMemberSchema])
async def get_house_members(
house_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Get all members of a house."""
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Get all members
result = await db.execute(
select(HouseMember).where(HouseMember.house_id == house_id)
)
members = result.scalars().all()
return members
@router.post(
"/{house_id}/members", response_model=HouseMemberResponse, status_code=status.HTTP_201_CREATED
)
async def add_house_member(
house_id: UUID,
member_in: HouseMemberCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Add a member to the house."""
# Check if user is an admin of the house
is_admin = await check_house_membership(
db, str(current_user.id), str(house_id), required_role="admin"
)
if not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Must be an admin to add members",
)
# Check if user exists
result = await db.execute(select(User).where(User.id == member_in.user_id))
user = result.scalars().first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Check if user is already a member
result = await db.execute(
select(HouseMember).where(
HouseMember.house_id == house_id,
HouseMember.user_id == member_in.user_id,
)
)
existing_member = result.scalars().first()
if existing_member:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User is already a member of this house",
)
# Add the member
db_member = HouseMember(
house_id=house_id,
user_id=member_in.user_id,
role=member_in.role,
)
db.add(db_member)
await db.commit()
await db.refresh(db_member)
return db_member
@router.put("/{house_id}/members/{user_id}", response_model=HouseMemberSchema)
async def update_house_member(
house_id: UUID,
user_id: UUID,
member_in: HouseMemberUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Update member role."""
# Check if user is an admin of the house
is_admin = await check_house_membership(
db, str(current_user.id), str(house_id), required_role="admin"
)
if not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Must be an admin to update member roles",
)
# Get the member
result = await db.execute(
select(HouseMember).where(
HouseMember.house_id == house_id,
HouseMember.user_id == user_id,
)
)
member = result.scalars().first()
if not member:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Member not found",
)
# Update member fields
update_data = member_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(member, field, value)
await db.commit()
await db.refresh(member)
return member
@router.delete("/{house_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_house_member(
house_id: UUID,
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Remove a member from the house."""
# Check if user is an admin of the house
is_admin = await check_house_membership(
db, str(current_user.id), str(house_id), required_role="admin"
)
if not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Must be an admin to remove members",
)
# Get the member
result = await db.execute(
select(HouseMember).where(
HouseMember.house_id == house_id,
HouseMember.user_id == user_id,
)
)
member = result.scalars().first()
if not member:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Member not found",
)
# Prevent removing the last admin
if member.role == "admin":
# Count admins
result = await db.execute(
select(HouseMember).where(
HouseMember.house_id == house_id,
HouseMember.role == "admin",
)
)
admins = result.scalars().all()
if len(admins) <= 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot remove the last admin of the house",
)
# Remove the member
await db.delete(member)
await db.commit()
return None
# Create a new invite (only admins can create invites)
@router.post(
"/{house_id}/invites",
response_model=HouseInviteSchema,
status_code=status.HTTP_201_CREATED,
)
async def create_house_invite(
house_id: UUID,
invite_in: HouseInviteCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""
Create an invitation code for a house with a set expiration.
Only an admin can create an invitation.
"""
is_admin = await check_house_membership(
db, str(current_user.id), str(house_id), required_role="admin"
)
if not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can create invitation codes",
)
# Generate a unique code
code = generate_invite_code()
# Check for uniqueness (in case of a rare collision)
while True:
result = await db.execute(select(HouseInvite).where(HouseInvite.code == code))
existing_invite = result.scalars().first()
if existing_invite:
code = generate_invite_code()
else:
break
expires_at = datetime.utcnow() + timedelta(minutes=invite_in.expires_in_minutes)
db_invite = HouseInvite(
code=code,
house_id=house_id,
inviter_id=current_user.id,
expires_at=expires_at,
)
db.add(db_invite)
await db.commit()
await db.refresh(db_invite)
return db_invite
# List active (nonexpired) invites for a house (accessible to house members)
@router.get(
"/{house_id}/invites",
response_model=list[HouseInviteSchema],
)
async def list_active_invites(
house_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""
Get all nonexpired invitation codes for a given house.
"""
is_member = await check_house_membership(
db, str(current_user.id), str(house_id)
)
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
invites_query = select(HouseInvite).where(
HouseInvite.house_id == house_id,
HouseInvite.expires_at > datetime.utcnow(),
)
result = await db.execute(invites_query)
invites = result.scalars().all()
return invites
# Accept an invite code (allows a user to join a house)
@router.post(
"/invites/accept",
response_model=HouseMemberResponse,
status_code=status.HTTP_201_CREATED,
)
async def accept_invite(
code: str,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""
Accept an invitation code to join a house.
If the invite has expired or is invalid, an error is raised.
"""
result = await db.execute(select(HouseInvite).where(HouseInvite.code == code))
invite = result.scalars().first()
if not invite:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Invalid invitation code",
)
if invite.expires_at < datetime.utcnow():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invitation code has expired",
)
# Check if the user is already a member
result = await db.execute(
select(HouseMember).where(
HouseMember.house_id == invite.house_id,
HouseMember.user_id == current_user.id,
)
)
existing_member = result.scalars().first()
if existing_member:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User is already a member of this house",
)
db_member = HouseMember(
house_id=invite.house_id,
user_id=current_user.id,
role="member", # default role on joining
)
db.add(db_member)
await db.commit()
await db.refresh(db_member)
return db_member

View File

Binary file not shown.

Binary file not shown.

109
app/app/api/ocr/router.py Normal file
View File

@ -0,0 +1,109 @@
# app/api/ocr/router.py
from typing import Annotated, List
from uuid import UUID
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import check_house_membership, get_current_user
from app.db.session import get_db
from app.models.shopping_list import ListItem, ShoppingList
from app.models.user import User
from app.schemas.shopping_list import ListItemCreate
from app.services.ocr_service import ocr_service
router = APIRouter()
@router.post("/process", response_model=List[dict])
async def process_image(
current_user: Annotated[User, Depends(get_current_user)],
image: UploadFile = File(...),
):
"""Process image and extract items."""
if not image.content_type.startswith("image/"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an image",
)
# Read the image file
image_bytes = await image.read()
# Process the image with OCR
items = await ocr_service.process_image(image_bytes)
return items
@router.post("/{house_id}/lists/{list_id}/ocr/apply")
async def apply_ocr_results(
house_id: UUID,
list_id: UUID,
items: List[dict],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Add OCR results to shopping list."""
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Check if the list belongs to the house
result = await db.execute(
select(ShoppingList).where(
ShoppingList.id == list_id,
ShoppingList.house_id == house_id,
)
)
shopping_list = result.scalars().first()
if not shopping_list:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Shopping list not found",
)
# Get the highest position to place new items at the end
result = await db.execute(
select(ListItem).where(ListItem.list_id == list_id).order_by(ListItem.position.desc())
)
last_item = result.scalars().first()
position = 1 if not last_item else (last_item.position or 0) + 1
# Add items to the list
added_items = []
for item_data in items:
# Create item schema for validation
try:
item_create = ListItemCreate(
name=item_data.get("name", "Unknown Item"),
quantity=item_data.get("quantity", 1),
price=item_data.get("price"),
unit=item_data.get("unit"),
)
# Create the item
db_item = ListItem(
list_id=list_id,
position=position,
**item_create.model_dump(),
)
db.add(db_item)
position += 1
added_items.append(item_data)
except Exception as e:
# Log the error but continue with other items
continue
await db.commit()
return {
"detail": f"Added {len(added_items)} items to the shopping list",
"items": added_items,
}

View File

View File

@ -0,0 +1,512 @@
# app/api/shopping_lists/router.py
from typing import Annotated, List
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import check_house_membership, get_current_user
from app.db.session import get_db
from app.models.shopping_list import ListItem, ShoppingList
from app.models.user import User
from app.schemas.shopping_list import (
ItemReorder,
ListItem as ListItemSchema,
ListItemCreate,
ListItemUpdate,
ShoppingList as ShoppingListSchema,
ShoppingListCreate,
ShoppingListUpdate,
)
from app.core.logger import shopping_lists_logger
router = APIRouter()
@router.get("/{house_id}/lists", response_model=List[ShoppingListSchema])
async def get_shopping_lists(
house_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Get all shopping lists for a house."""
shopping_lists_logger.debug(f"User {current_user.id} requested shopping lists for house {house_id}")
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Get all shopping lists for the house
result = await db.execute(
select(ShoppingList).where(ShoppingList.house_id == house_id)
)
lists = result.scalars().all()
return lists
@router.post(
"/{house_id}/lists", response_model=ShoppingListSchema, status_code=status.HTTP_201_CREATED
)
async def create_shopping_list(
house_id: UUID,
list_in: ShoppingListCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Create new shopping list for a house."""
shopping_lists_logger.debug(f"User {current_user.id} is creating a new shopping list for house {house_id}")
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Create the shopping list
db_list = ShoppingList(
house_id=house_id,
**list_in.model_dump(),
)
db.add(db_list)
await db.commit()
await db.refresh(db_list)
return db_list
@router.get("/{house_id}/lists/{list_id}", response_model=ShoppingListSchema)
async def get_shopping_list(
house_id: UUID,
list_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Get single list details."""
shopping_lists_logger.debug(f"User {current_user.id} requested details for list {list_id} in house {house_id}")
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Get the shopping list
result = await db.execute(
select(ShoppingList).where(
ShoppingList.id == list_id,
ShoppingList.house_id == house_id,
)
)
shopping_list = result.scalars().first()
if not shopping_list:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Shopping list not found",
)
return shopping_list
@router.put("/{house_id}/lists/{list_id}", response_model=ShoppingListSchema)
async def update_shopping_list(
house_id: UUID,
list_id: UUID,
list_in: ShoppingListUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Update list details."""
shopping_lists_logger.debug(f"User {current_user.id} is updating list {list_id} in house {house_id}")
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Get the shopping list
result = await db.execute(
select(ShoppingList).where(
ShoppingList.id == list_id,
ShoppingList.house_id == house_id,
)
)
shopping_list = result.scalars().first()
if not shopping_list:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Shopping list not found",
)
# Update shopping list fields
update_data = list_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(shopping_list, field, value)
await db.commit()
await db.refresh(shopping_list)
return shopping_list
@router.delete("/{house_id}/lists/{list_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_shopping_list(
house_id: UUID,
list_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Delete/archive list."""
shopping_lists_logger.debug(f"User {current_user.id} is deleting list {list_id} in house {house_id}")
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Get the shopping list
result = await db.execute(
select(ShoppingList).where(
ShoppingList.id == list_id,
ShoppingList.house_id == house_id,
)
)
shopping_list = result.scalars().first()
if not shopping_list:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Shopping list not found",
)
# Archive the list instead of deleting
shopping_list.is_archived = True
await db.commit()
return None
# List Items endpoints
@router.get("/{house_id}/lists/{list_id}/items", response_model=List[ListItemSchema])
async def get_list_items(
house_id: UUID,
list_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Get all items in a list."""
shopping_lists_logger.debug(f"User {current_user.id} requested items for list {list_id} in house {house_id}")
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Check if the list belongs to the house
result = await db.execute(
select(ShoppingList).where(
ShoppingList.id == list_id,
ShoppingList.house_id == house_id,
)
)
shopping_list = result.scalars().first()
if not shopping_list:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Shopping list not found",
)
# Get all items in the list
result = await db.execute(select(ListItem).where(ListItem.list_id == list_id))
items = result.scalars().all()
return items
@router.post(
"/{house_id}/lists/{list_id}/items",
response_model=ListItemSchema,
status_code=status.HTTP_201_CREATED,
)
async def add_list_item(
house_id: UUID,
list_id: UUID,
item_in: ListItemCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Add item to list."""
shopping_lists_logger.debug(f"User {current_user.id} is adding an item to list {list_id} in house {house_id}")
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Check if the list belongs to the house
result = await db.execute(
select(ShoppingList).where(
ShoppingList.id == list_id,
ShoppingList.house_id == house_id,
)
)
shopping_list = result.scalars().first()
if not shopping_list:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Shopping list not found",
)
# Get the highest position to place the new item at the end
result = await db.execute(
select(ListItem).where(ListItem.list_id == list_id).order_by(ListItem.position.desc())
)
last_item = result.scalars().first()
new_position = 1 if not last_item else (last_item.position or 0) + 1
# Create the item
db_item = ListItem(
list_id=list_id,
position=new_position,
**item_in.model_dump(),
)
db.add(db_item)
await db.commit()
await db.refresh(db_item)
return db_item
@router.put("/{house_id}/lists/{list_id}/items/{item_id}", response_model=ListItemSchema)
async def update_list_item(
house_id: UUID,
list_id: UUID,
item_id: UUID,
item_in: ListItemUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Update item."""
shopping_lists_logger.debug(f"User {current_user.id} is updating item {item_id} in list {list_id} in house {house_id}")
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Check if the list belongs to the house
result = await db.execute(
select(ShoppingList).where(
ShoppingList.id == list_id,
ShoppingList.house_id == house_id,
)
)
shopping_list = result.scalars().first()
if not shopping_list:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Shopping list not found",
)
# Get the item
result = await db.execute(
select(ListItem).where(
ListItem.id == item_id,
ListItem.list_id == list_id,
)
)
item = result.scalars().first()
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Item not found",
)
# Update item fields
update_data = item_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(item, field, value)
await db.commit()
await db.refresh(item)
return item
@router.delete(
"/{house_id}/lists/{list_id}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT
)
async def delete_list_item(
house_id: UUID,
list_id: UUID,
item_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Delete item."""
shopping_lists_logger.debug(f"User {current_user.id} is deleting item {item_id} in list {list_id} in house {house_id}")
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Check if the list belongs to the house
result = await db.execute(
select(ShoppingList).where(
ShoppingList.id == list_id,
ShoppingList.house_id == house_id,
)
)
shopping_list = result.scalars().first()
if not shopping_list:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Shopping list not found",
)
# Get the item
result = await db.execute(
select(ListItem).where(
ListItem.id == item_id,
ListItem.list_id == list_id,
)
)
item = result.scalars().first()
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Item not found",
)
# Delete the item
await db.delete(item)
await db.commit()
return None
@router.patch(
"/{house_id}/lists/{list_id}/items/{item_id}/complete", response_model=ListItemSchema
)
async def mark_item_complete(
house_id: UUID,
list_id: UUID,
item_id: UUID,
is_completed: bool,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Mark item as complete/incomplete."""
shopping_lists_logger.debug(f"User {current_user.id} is marking item {item_id} as {'complete' if is_completed else 'incomplete'} in list {list_id} in house {house_id}")
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Check if the list belongs to the house
result = await db.execute(
select(ShoppingList).where(
ShoppingList.id == list_id,
ShoppingList.house_id == house_id,
)
)
shopping_list = result.scalars().first()
if not shopping_list:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Shopping list not found",
)
# Get the item
result = await db.execute(
select(ListItem).where(
ListItem.id == item_id,
ListItem.list_id == list_id,
)
)
item = result.scalars().first()
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Item not found",
)
# Update completion status
item.is_completed = is_completed
await db.commit()
await db.refresh(item)
return item
@router.post("/{house_id}/lists/{list_id}/items/reorder")
async def reorder_items(
house_id: UUID,
list_id: UUID,
reorder_data: List[ItemReorder],
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
):
"""Reorder items in list."""
shopping_lists_logger.debug(f"User {current_user.id} is reordering items in list {list_id} in house {house_id}")
# Check if user is a member of the house
is_member = await check_house_membership(db, str(current_user.id), str(house_id))
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this house",
)
# Check if the list belongs to the house
result = await db.execute(
select(ShoppingList).where(
ShoppingList.id == list_id,
ShoppingList.house_id == house_id,
)
)
shopping_list = result.scalars().first()
if not shopping_list:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Shopping list not found",
)
# Update positions for each item
for item_order in reorder_data:
result = await db.execute(
select(ListItem).where(
ListItem.id == item_order.item_id,
ListItem.list_id == list_id,
)
)
item = result.scalars().first()
if item:
item.position = item_order.new_position
await db.commit()
return {"detail": "Items reordered successfully"}

View File

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,52 @@
# app/api/users/router.py
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.core.dependencies import get_current_user
from app.models.user import User as UserModel
from app.schemas.user import User, UserUpdate
from app.core.security import get_password_hash
from app.core.logger import logger
router = APIRouter()
@router.get("/me", response_model=User)
async def read_current_user(
current_user: Annotated[UserModel, Depends(get_current_user)]
):
"""
Retrieve the current user's profile.
"""
logger.info(f"User {current_user.id} profile retrieved")
return current_user
@router.put("/me", response_model=User)
async def update_current_user(
user_in: UserUpdate,
current_user: Annotated[UserModel, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""
Update the current users profile.
If a password is provided, it will be hashed before also updating.
"""
update_data = user_in.model_dump(exclude_unset=True)
# If password is provided in the update, hash it first.
if "password" in update_data:
update_data["password_hash"] = get_password_hash(update_data.pop("password"))
# Update each field on the current user
for field, value in update_data.items():
setattr(current_user, field, value)
db.add(current_user)
await db.commit()
await db.refresh(current_user)
logger.info(f"User {current_user.id} profile updated")
return current_user

0
app/app/core/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

57
app/app/core/config.py Normal file
View File

@ -0,0 +1,57 @@
# app/core/config.py
import secrets
from typing import Any, Dict, List, Optional, Union
from pydantic import AnyHttpUrl, PostgresDsn, validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
API_V1_STR: str = "/api"
SECRET_KEY: str = secrets.token_urlsafe(32)
# 60 minutes * 24 hours * 8 days = 8 days
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
SERVER_NAME: str = "HouseHold API"
SERVER_HOST: AnyHttpUrl = "http://localhost:8000"
# BACKEND_CORS_ORIGINS is a JSON-formatted list of origins
# e.g: ''
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
elif isinstance(v, (list, str)):
return v
raise ValueError(v)
PROJECT_NAME: str = "HouseHold API"
POSTGRES_SERVER: str = "localhost"
POSTGRES_USER: str = "postgres"
POSTGRES_PASSWORD: str = "postgres"
POSTGRES_DB: str = "household"
SQLALCHEMY_DATABASE_URI: Optional[PostgresDsn] = None
@validator("SQLALCHEMY_DATABASE_URI", pre=True)
def assemble_db_connection(cls, v: Optional[str], values: Dict[str, Any]) -> Any:
if isinstance(v, str):
return v
return PostgresDsn.build(
scheme="postgresql+asyncpg",
username=values.get("POSTGRES_USER"),
password=values.get("POSTGRES_PASSWORD"),
host=values.get("POSTGRES_SERVER"),
path=f"/{values.get('POSTGRES_DB') or ''}",
)
# OCR Service
OCR_API_KEY: Optional[str] = None
OCR_SERVICE_URL: Optional[str] = None
class Config:
case_sensitive = True
env_file = ".env"
settings = Settings()

View File

@ -0,0 +1,63 @@
# app/core/dependencies.py
from typing import Annotated, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.core.config import settings
from app.core.security import ALGORITHM
from app.db.session import get_db
from app.models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
async def get_current_user(
db: Annotated[AsyncSession, Depends(get_db)],
token: Annotated[str, Depends(oauth2_scheme)],
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalars().first()
if user is None:
raise credentials_exception
return user
async def check_house_membership(
db: AsyncSession, user_id: str, house_id: str, required_role: Optional[str] = None
) -> bool:
"""Check if a user is a member of a house with optional role check."""
from app.models.house import HouseMember
query = select(HouseMember).where(
HouseMember.user_id == user_id,
HouseMember.house_id == house_id
)
result = await db.execute(query)
membership = result.scalars().first()
if not membership:
return False
if required_role and membership.role != required_role:
return False
return True

64
app/app/core/logger.py Normal file
View File

@ -0,0 +1,64 @@
# app/core/logger.py
import logging
import logging.config
def configure_logging():
"""Configure basic logging."""
logging_config = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
}
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": "DEBUG",
"formatter": "simple",
"stream": "ext://sys.stdout",
},
"file": {
"class": "logging.FileHandler",
"level": "INFO",
"formatter": "simple",
"filename": "household_api.log",
"mode": "a", # Append to the log file
},
},
"loggers": {
"api": {
"handlers": ["console", "file"],
"level": "DEBUG",
"propagate": True,
},
"services": {
"handlers": ["console", "file"],
"level": "INFO",
"propagate": True,
},
"db": {
"handlers": ["console", "file"],
"level": "WARN",
"propagate": True,
},
"shopping_lists": {
"handlers": ["console", "file"],
"level": "INFO",
"propagate": True,
},
},
"root": {
"level": "WARNING",
"handlers": ["console", "file"],
},
}
logging.config.dictConfig(logging_config)
# Initialize logging configuration
configure_logging()
# Get logger instance for use in other modules
logger = logging.getLogger("api")
shopping_lists_logger = logging.getLogger("shopping_lists")

34
app/app/core/security.py Normal file
View File

@ -0,0 +1,34 @@
# app/core/security.py
from datetime import datetime, timedelta
from typing import Any, Optional, Union
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
def create_access_token(
subject: Union[str, Any], expires_delta: Optional[timedelta] = None
) -> str:
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)

0
app/app/db/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

17
app/app/db/base.py Normal file
View File

@ -0,0 +1,17 @@
# app/db/base.py
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_async_engine(
str(settings.SQLALCHEMY_DATABASE_URI),
echo=True,
future=True,
)
AsyncSessionLocal = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
Base = declarative_base()

0
app/app/db/init_db.py Normal file
View File

18
app/app/db/session.py Normal file
View File

@ -0,0 +1,18 @@
# app/db/session.py
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import AsyncSessionLocal
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

66
app/app/main.py Normal file
View File

@ -0,0 +1,66 @@
# app/main.py
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from app.core.logger import logger
from app.api.auth.router import router as auth_router
from app.api.users.router import router as users_router
from app.api.chores.router import router as chores_router
from app.api.expenses.router import router as expenses_router
from app.api.houses.router import router as houses_router
from app.api.ocr.router import router as ocr_router
from app.api.shopping_lists.router import router as shopping_lists_router
from app.core.config import settings
app = FastAPI(
title=settings.PROJECT_NAME,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
)
# Set all CORS enabled origins
if settings.BACKEND_CORS_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def log_requests(request: Request, call_next):
logger.info(f"Request: {request.method} {request.url}")
response = await call_next(request)
logger.info(f"Response: {response.status_code}")
return response
@app.on_event("startup")
async def startup_event():
logger.info("Starting up...")
@app.on_event("shutdown")
async def shutdown_event():
logger.info("Shutting down...")
# Include routers
app.include_router(auth_router, prefix=f"{settings.API_V1_STR}/auth", tags=["auth"])
app.include_router(houses_router, prefix=f"{settings.API_V1_STR}/houses", tags=["houses"])
app.include_router(
shopping_lists_router, prefix=f"{settings.API_V1_STR}/houses", tags=["shopping_lists"]
)
app.include_router(
expenses_router, prefix=f"{settings.API_V1_STR}/houses", tags=["expenses"]
)
app.include_router(
chores_router, prefix=f"{settings.API_V1_STR}/houses", tags=["chores"]
)
app.include_router(ocr_router, prefix=f"{settings.API_V1_STR}/ocr", tags=["ocr"])
app.include_router(
users_router,
prefix=f"{settings.API_V1_STR}/users",
tags=["users"]
)
@app.get("/")
async def root():
return {"message": "Welcome to the Household API"}

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

27
app/app/models/chore.py Normal file
View File

@ -0,0 +1,27 @@
# app/models/chore.py
import uuid
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.db.base import Base
class Chore(Base):
__tablename__ = "chores"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
house_id = Column(UUID(as_uuid=True), ForeignKey("houses.id"), nullable=False)
title = Column(String(255), nullable=False)
description = Column(Text)
assigned_to = Column(UUID(as_uuid=True), ForeignKey("users.id"))
due_date = Column(DateTime)
is_completed = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
house = relationship("House", back_populates="chores")
assignee = relationship("User")

40
app/app/models/expense.py Normal file
View File

@ -0,0 +1,40 @@
# app/models/expense.py
import uuid
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Numeric, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.db.base import Base
class Expense(Base):
__tablename__ = "expenses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
list_id = Column(UUID(as_uuid=True), ForeignKey("shopping_lists.id"), nullable=False)
payer_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
amount = Column(Numeric(10, 2), nullable=False)
description = Column(Text)
date = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
shopping_list = relationship("ShoppingList", back_populates="expenses")
payer = relationship("User")
splits = relationship("ExpenseSplit", back_populates="expense", cascade="all, delete-orphan")
class ExpenseSplit(Base):
__tablename__ = "expense_splits"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
expense_id = Column(UUID(as_uuid=True), ForeignKey("expenses.id"), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
amount = Column(Numeric(10, 2), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
expense = relationship("Expense", back_populates="splits")
user = relationship("User")

55
app/app/models/house.py Normal file
View File

@ -0,0 +1,55 @@
# app/models/house.py
import uuid
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.db.base import Base
class House(Base):
__tablename__ = "houses"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(255), nullable=False)
description = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
members = relationship(
"HouseMember",
back_populates="house",
cascade="all, delete-orphan"
)
shopping_lists = relationship(
"ShoppingList",
back_populates="house",
cascade="all, delete-orphan"
)
chores = relationship(
"Chore",
back_populates="house",
cascade="all, delete-orphan"
)
invites = relationship(
"HouseInvite",
back_populates="house",
cascade="all, delete-orphan"
)
class HouseMember(Base):
__tablename__ = "house_members"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
house_id = Column(UUID(as_uuid=True), ForeignKey("houses.id"), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
role = Column(String(50), default="member")
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
house = relationship("House", back_populates="members")
user = relationship("User")

25
app/app/models/invite.py Normal file
View File

@ -0,0 +1,25 @@
# app/models/invite.py
import uuid
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.db.base import Base
class HouseInvite(Base):
__tablename__ = "house_invites"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
code = Column(String(255), unique=True, nullable=False, index=True)
house_id = Column(UUID(as_uuid=True), ForeignKey("houses.id"), nullable=False)
inviter_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
expires_at = Column(DateTime, nullable=False)
# Relationships
# (The host-side as a “child” of House; see also the update below in app/models/house.py)
house = relationship("House", back_populates="invites")
inviter = relationship("User")

View File

@ -0,0 +1,53 @@
# app/models/shopping_list.py
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
Numeric,
String,
Text,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.db.base import Base
class ShoppingList(Base):
__tablename__ = "shopping_lists"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
house_id = Column(UUID(as_uuid=True), ForeignKey("houses.id"), nullable=False)
title = Column(String(255), nullable=False)
description = Column(Text)
is_archived = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
house = relationship("House", back_populates="shopping_lists")
items = relationship("ListItem", back_populates="shopping_list", cascade="all, delete-orphan")
expenses = relationship("Expense", back_populates="shopping_list", cascade="all, delete-orphan")
class ListItem(Base):
__tablename__ = "list_items"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
list_id = Column(UUID(as_uuid=True), ForeignKey("shopping_lists.id"), nullable=False)
name = Column(String(255), nullable=False)
quantity = Column(Numeric(10, 2), default=1)
unit = Column(String(50))
price = Column(Numeric(10, 2))
is_completed = Column(Boolean, default=False)
position = Column(Integer)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
shopping_list = relationship("ShoppingList", back_populates="items")

19
app/app/models/user.py Normal file
View File

@ -0,0 +1,19 @@
# app/models/user.py
import uuid
from datetime import datetime
from sqlalchemy import Column, DateTime, String
from sqlalchemy.dialects.postgresql import UUID
from app.db.base import Base
class User(Base):
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String(255), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
full_name = Column(String(255))
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

42
app/app/schemas/chore.py Normal file
View File

@ -0,0 +1,42 @@
# app/schemas/chore.py
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
class ChoreBase(BaseModel):
title: str
description: Optional[str] = None
due_date: Optional[datetime] = None
class ChoreCreate(ChoreBase):
assigned_to: Optional[UUID] = None
class ChoreUpdate(ChoreBase):
title: Optional[str] = None
assigned_to: Optional[UUID] = None
is_completed: Optional[bool] = None
class ChoreAssign(BaseModel):
assigned_to: UUID
class ChoreComplete(BaseModel):
is_completed: bool
class Chore(ChoreBase):
id: UUID
house_id: UUID
assigned_to: Optional[UUID] = None
is_completed: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,57 @@
# app/schemas/expense.py
from datetime import datetime
from decimal import Decimal
from typing import List, Optional
from uuid import UUID
from pydantic import BaseModel
class ExpenseSplitBase(BaseModel):
user_id: UUID
amount: Decimal
class ExpenseSplitCreate(ExpenseSplitBase):
pass
class ExpenseSplit(ExpenseSplitBase):
id: UUID
expense_id: UUID
created_at: datetime
class Config:
from_attributes = True
class ExpenseBase(BaseModel):
amount: Decimal
description: Optional[str] = None
date: Optional[datetime] = None
class ExpenseCreate(ExpenseBase):
payer_id: UUID
splits: List[ExpenseSplitCreate]
class ExpenseUpdate(ExpenseBase):
payer_id: Optional[UUID] = None
splits: Optional[List[ExpenseSplitCreate]] = None
class Expense(ExpenseBase):
id: UUID
list_id: UUID
payer_id: UUID
created_at: datetime
splits: List[ExpenseSplit] = []
class Config:
from_attributes = True
class ExpenseSummary(BaseModel):
total_amount: Decimal
user_balances: dict[UUID, Decimal]

62
app/app/schemas/house.py Normal file
View File

@ -0,0 +1,62 @@
# app/schemas/house.py
from datetime import datetime
from typing import List, Optional
from uuid import UUID
from pydantic import BaseModel
class HouseBase(BaseModel):
name: str
description: Optional[str] = None
class HouseCreate(HouseBase):
pass
class HouseUpdate(HouseBase):
name: Optional[str] = None
class HouseMemberBase(BaseModel):
role: str = "member"
class HouseMemberCreate(HouseMemberBase):
user_id: UUID
class HouseMemberUpdate(HouseMemberBase):
pass
class HouseMember(HouseMemberBase):
id: UUID
house_id: UUID
user_id: UUID
created_at: datetime
class Config:
from_attributes = True
class HouseMemberResponse(BaseModel):
id: UUID
house_id: UUID
user_id: UUID
role: str
created_at: datetime
class Config:
orm_mode = True
class House(HouseBase):
id: UUID
created_at: datetime
updated_at: datetime
members: List[HouseMember] = []
class Config:
from_attributes = True

26
app/app/schemas/invite.py Normal file
View File

@ -0,0 +1,26 @@
# app/schemas/invite.py
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class HouseInviteBase(BaseModel):
pass
class HouseInviteCreate(HouseInviteBase):
# Number of minutes until this invite expires (default: 60 minutes)
expires_in_minutes: int = Field(default=60)
class HouseInvite(HouseInviteBase):
id: UUID
code: str
house_id: UUID
inviter_id: UUID
created_at: datetime
expires_at: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,66 @@
# app/schemas/shopping_list.py
from datetime import datetime
from decimal import Decimal
from typing import List, Optional
from uuid import UUID
from pydantic import BaseModel, Field
class ListItemBase(BaseModel):
name: str
quantity: Optional[Decimal] = Field(1, ge=0)
unit: Optional[str] = None
price: Optional[Decimal] = None
position: Optional[int] = None
class ListItemCreate(ListItemBase):
pass
class ListItemUpdate(ListItemBase):
name: Optional[str] = None
is_completed: Optional[bool] = None
class ListItem(ListItemBase):
id: UUID
list_id: UUID
is_completed: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class ShoppingListBase(BaseModel):
title: str
description: Optional[str] = None
class ShoppingListCreate(ShoppingListBase):
pass
class ShoppingListUpdate(ShoppingListBase):
title: Optional[str] = None
is_archived: Optional[bool] = None
class ShoppingList(ShoppingListBase):
id: UUID
house_id: UUID
is_archived: bool
created_at: datetime
updated_at: datetime
items: List[ListItem] = []
class Config:
from_attributes = True
class ItemReorder(BaseModel):
item_id: UUID
new_position: int

37
app/app/schemas/user.py Normal file
View File

@ -0,0 +1,37 @@
# app/schemas/user.py
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
class UserBase(BaseModel):
email: Optional[EmailStr] = None
full_name: Optional[str] = None
class UserCreate(UserBase):
email: EmailStr
password: str = Field(..., min_length=8)
class UserUpdate(UserBase):
password: Optional[str] = Field(None, min_length=8)
class UserInDBBase(UserBase):
id: UUID
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class User(UserInDBBase):
pass
class UserInDB(UserInDBBase):
password_hash: str

View File

Binary file not shown.

View File

View File

View File

View File

View File

@ -0,0 +1,16 @@
# app/services/invite_service.py
import random
ADJECTIVES = [
"happy", "bright", "blue", "swift", "gentle", "fancy", "warm", "lucky",
"brave", "calm", "eager", "jolly"
]
NOUNS = [
"panda", "tiger", "river", "forest", "sky", "mountain", "ocean", "falcon",
"eagle", "lion", "wolf", "bear"
]
def generate_invite_code() -> str:
"""Return a random code made up of an adjective and a noun."""
return f"{random.choice(ADJECTIVES)}-{random.choice(NOUNS)}"

View File

@ -0,0 +1,89 @@
# app/services/ocr_service.py
import io
import logging
from typing import List, Optional
# from google.cloud import vision
# from google.cloud.vision_v1 import types
from app.core.config import settings
from app.schemas.shopping_list import ListItemCreate
logger = logging.getLogger(__name__)
class OCRService:
def __init__(self):
self.client = None
if settings.OCR_API_KEY:
try:
self.client = vision.ImageAnnotatorClient.from_service_account_json(
settings.OCR_API_KEY
)
except Exception as e:
logger.error(f"Failed to initialize OCR client: {e}")
async def process_image(self, image_bytes: bytes) -> List[dict]:
"""Process an image and extract text."""
if not self.client:
logger.error("OCR client not initialized")
return []
try:
image = types.Image(content=image_bytes)
response = self.client.text_detection(image=image)
texts = response.text_annotations
# The first text annotation contains the entire detected text
if not texts:
return []
# Extract potential shopping items from the text
full_text = texts[0].description
return self._extract_items_from_text(full_text)
except Exception as e:
logger.error(f"Error processing image: {e}")
return []
def _extract_items_from_text(self, text: str) -> List[dict]:
"""Extract potential shopping items from the OCR text."""
# This is a simplified implementation
# In a real-world scenario, you would use more sophisticated NLP techniques
lines = text.split("\n")
items = []
for line in lines:
# Skip very short lines or lines that are likely headers
if len(line) < 3 or line.isupper() or "TOTAL" in line.upper():
continue
# Try to extract item name and potentially price
parts = line.split()
if not parts:
continue
# Simple heuristic: look for price patterns
item_name = " ".join(parts[:-1]) if len(parts) > 1 else line
price = None
# Check if the last part looks like a price
if len(parts) > 1 and parts[-1].replace(".", "").replace(",", "").isdigit():
try:
price = float(parts[-1].replace(",", "."))
item_name = " ".join(parts[:-1])
except ValueError:
pass
# Add the item if we have a name
if item_name.strip():
items.append({
"name": item_name.strip(),
"price": price,
"quantity": 1,
})
return items
ocr_service = OCRService()

31
app/docker-compose.yml Normal file
View File

@ -0,0 +1,31 @@
# docker-compose.yml
version: '3'
services:
api:
build: .
ports:
- "8000:8000"
depends_on:
- db
environment:
- POSTGRES_SERVER=db
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=household
volumes:
- ./:/app/
db:
image: postgres:15
volumes:
- postgres_data:/var/lib/postgresql/data/
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=household
ports:
- "5432:5432"
volumes:
postgres_data:

38
app/household_api.log Normal file
View File

@ -0,0 +1,38 @@
2025-03-25 14:29:37,555 - api - INFO - Starting up...
2025-03-25 14:29:37,555 - api - INFO - Starting up...
2025-03-25 14:34:36,079 - api - INFO - Shutting down...
2025-03-25 14:34:36,079 - api - INFO - Shutting down...
2025-03-25 14:34:37,899 - api - INFO - Starting up...
2025-03-25 14:34:37,899 - api - INFO - Starting up...
2025-03-25 15:36:53,193 - api - INFO - Shutting down...
2025-03-25 15:36:53,193 - api - INFO - Shutting down...
2025-03-25 15:36:55,243 - api - INFO - Starting up...
2025-03-25 15:36:55,243 - api - INFO - Starting up...
2025-03-25 16:04:47,516 - api - INFO - Starting up...
2025-03-25 16:04:47,516 - api - INFO - Starting up...
2025-03-25 16:07:04,886 - api - INFO - Request: OPTIONS http://localhost:8000/api/auth/register
2025-03-25 16:07:04,886 - api - INFO - Request: OPTIONS http://localhost:8000/api/auth/register
2025-03-25 16:07:04,886 - api - INFO - Response: 400
2025-03-25 16:07:04,886 - api - INFO - Response: 400
2025-03-25 16:10:14,792 - api - INFO - Request: POST http://localhost:8000/api/auth/login
2025-03-25 16:10:14,792 - api - INFO - Request: POST http://localhost:8000/api/auth/login
2025-03-25 16:10:17,590 - api - INFO - Request: POST http://localhost:8000/api/auth/login
2025-03-25 16:10:17,590 - api - INFO - Request: POST http://localhost:8000/api/auth/login
2025-03-25 20:57:50,843 - api - INFO - Shutting down...
2025-03-25 20:57:50,843 - api - INFO - Shutting down...
2025-03-25 20:57:54,809 - api - INFO - Starting up...
2025-03-25 20:57:54,809 - api - INFO - Starting up...
2025-03-25 20:58:16,244 - api - INFO - Request: POST http://localhost:8000/api/auth/login
2025-03-25 20:58:16,244 - api - INFO - Request: POST http://localhost:8000/api/auth/login
2025-03-25 20:58:17,139 - sqlalchemy.engine.Engine - INFO - select pg_catalog.version()
2025-03-25 20:58:17,139 - sqlalchemy.engine.Engine - INFO - [raw sql] ()
2025-03-25 20:58:17,197 - sqlalchemy.engine.Engine - INFO - select current_schema()
2025-03-25 20:58:17,197 - sqlalchemy.engine.Engine - INFO - [raw sql] ()
2025-03-25 20:58:17,275 - sqlalchemy.engine.Engine - INFO - show standard_conforming_strings
2025-03-25 20:58:17,276 - sqlalchemy.engine.Engine - INFO - [raw sql] ()
2025-03-25 20:58:17,319 - sqlalchemy.engine.Engine - INFO - BEGIN (implicit)
2025-03-25 20:58:17,348 - sqlalchemy.engine.Engine - INFO - SELECT users.id, users.email, users.password_hash, users.full_name, users.created_at, users.updated_at
FROM users
WHERE users.email = $1::VARCHAR
2025-03-25 20:58:17,348 - sqlalchemy.engine.Engine - INFO - [generated in 0.00074s] ('mo@mo.mo',)
2025-03-25 20:58:17,401 - sqlalchemy.engine.Engine - INFO - ROLLBACK

27
app/pyproject.toml Normal file
View File

@ -0,0 +1,27 @@
[project]
name = "dooey"
version = "0.1.0"
description = "Household management API"
dependencies = [
"fastapi>=0.104.0",
"uvicorn>=0.23.2",
"sqlalchemy>=2.0.22",
"alembic>=1.12.0",
"asyncpg>=0.28.0",
"pydantic>=2.4.2",
"pydantic-settings>=2.0.3",
"python-jose>=3.3.0",
"passlib>=1.7.4",
"python-multipart>=0.0.6",
"bcrypt>=4.0.1"
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.2",
"pytest-asyncio>=0.21.1",
"httpx>=0.25.0",
"black>=23.10.0",
"isort>=5.12.0",
"mypy>=1.6.1"
]

50
app/tests/api/auth.py Normal file
View File

@ -0,0 +1,50 @@
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.main import app
from app.db.session import get_db
from app.models.user import User
@pytest.fixture
async def client():
async with AsyncClient(app=app, base_url="http://test") as ac:
yield ac
@pytest.fixture
async def db_session():
async with AsyncSession() as session:
yield session
@pytest.mark.asyncio
async def test_register(client: AsyncClient, db_session: AsyncSession):
response = await client.post("/register", json={
"email": "test@example.com",
"password": "password123",
"full_name": "Test User"
})
assert response.status_code == 200
data = response.json()
assert data["email"] == "test@example.com"
assert "id" in data
@pytest.mark.asyncio
async def test_login(client: AsyncClient, db_session: AsyncSession):
# Create a user first
user = User(
email="test@example.com",
password_hash=get_password_hash("password123"),
full_name="Test User"
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
response = await client.post("/login", data={
"username": "test@example.com",
"password": "password123"
})
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"

View File

@ -0,0 +1,109 @@
import pytest
from httpx import AsyncClient
from fastapi import status
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.shopping_list import ShoppingList, ListItem
from app.schemas.shopping_list import ShoppingListCreate, ListItemCreate
@pytest.mark.asyncio
async def test_get_shopping_lists(client: AsyncClient, db_session: AsyncSession, test_user, test_house):
response = await client.get(f"/{test_house.id}/lists", headers={"Authorization": f"Bearer {test_user.token}"})
assert response.status_code == status.HTTP_200_OK
@pytest.mark.asyncio
async def test_create_shopping_list(client: AsyncClient, db_session: AsyncSession, test_user, test_house):
shopping_list_data = ShoppingListCreate(name="Groceries")
response = await client.post(
f"/{test_house.id}/lists",
json=shopping_list_data.dict(),
headers={"Authorization": f"Bearer {test_user.token}"}
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["name"] == shopping_list_data.name
@pytest.mark.asyncio
async def test_get_shopping_list(client: AsyncClient, db_session: AsyncSession, test_user, test_house, test_shopping_list):
response = await client.get(
f"/{test_house.id}/lists/{test_shopping_list.id}",
headers={"Authorization": f"Bearer {test_user.token}"}
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["id"] == str(test_shopping_list.id)
@pytest.mark.asyncio
async def test_update_shopping_list(client: AsyncClient, db_session: AsyncSession, test_user, test_house, test_shopping_list):
update_data = {"name": "Updated List"}
response = await client.put(
f"/{test_house.id}/lists/{test_shopping_list.id}",
json=update_data,
headers={"Authorization": f"Bearer {test_user.token}"}
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["name"] == update_data["name"]
@pytest.mark.asyncio
async def test_delete_shopping_list(client: AsyncClient, db_session: AsyncSession, test_user, test_house, test_shopping_list):
response = await client.delete(
f"/{test_house.id}/lists/{test_shopping_list.id}",
headers={"Authorization": f"Bearer {test_user.token}"}
)
assert response.status_code == status.HTTP_204_NO_CONTENT
@pytest.mark.asyncio
async def test_get_list_items(client: AsyncClient, db_session: AsyncSession, test_user, test_house, test_shopping_list):
response = await client.get(
f"/{test_house.id}/lists/{test_shopping_list.id}/items",
headers={"Authorization": f"Bearer {test_user.token}"}
)
assert response.status_code == status.HTTP_200_OK
@pytest.mark.asyncio
async def test_add_list_item(client: AsyncClient, db_session: AsyncSession, test_user, test_house, test_shopping_list):
item_data = ListItemCreate(name="Milk", quantity=2)
response = await client.post(
f"/{test_house.id}/lists/{test_shopping_list.id}/items",
json=item_data.dict(),
headers={"Authorization": f"Bearer {test_user.token}"}
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["name"] == item_data.name
@pytest.mark.asyncio
async def test_update_list_item(client: AsyncClient, db_session: AsyncSession, test_user, test_house, test_shopping_list, test_list_item):
update_data = {"name": "Updated Item"}
response = await client.put(
f"/{test_house.id}/lists/{test_shopping_list.id}/items/{test_list_item.id}",
json=update_data,
headers={"Authorization": f"Bearer {test_user.token}"}
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["name"] == update_data["name"]
@pytest.mark.asyncio
async def test_delete_list_item(client: AsyncClient, db_session: AsyncSession, test_user, test_house, test_shopping_list, test_list_item):
response = await client.delete(
f"/{test_house.id}/lists/{test_shopping_list.id}/items/{test_list_item.id}",
headers={"Authorization": f"Bearer {test_user.token}"}
)
assert response.status_code == status.HTTP_204_NO_CONTENT
@pytest.mark.asyncio
async def test_mark_item_complete(client: AsyncClient, db_session: AsyncSession, test_user, test_house, test_shopping_list, test_list_item):
response = await client.patch(
f"/{test_house.id}/lists/{test_shopping_list.id}/items/{test_list_item.id}/complete",
json={"is_completed": True},
headers={"Authorization": f"Bearer {test_user.token}"}
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["is_completed"] is True
@pytest.mark.asyncio
async def test_reorder_items(client: AsyncClient, db_session: AsyncSession, test_user, test_house, test_shopping_list, test_list_item):
reorder_data = [{"item_id": str(test_list_item.id), "new_position": 1}]
response = await client.post(
f"/{test_house.id}/lists/{test_shopping_list.id}/items/reorder",
json=reorder_data,
headers={"Authorization": f"Bearer {test_user.token}"}
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["detail"] == "Items reordered successfully"

0
app/tests/conftest.py Normal file
View File

Some files were not shown because too many files have changed in this diff Show More