weeee💃
This commit is contained in:
commit
240e54eec4
8
app/.env
Normal file
8
app/.env
Normal 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
0
app/.gitignore
vendored
Normal file
22
app/Dockerfile
Normal file
22
app/Dockerfile
Normal 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
0
app/README.md
Normal file
38
app/alembic.ini
Normal file
38
app/alembic.ini
Normal 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
|
BIN
app/alembic/__pycache__/env.cpython-312.pyc
Normal file
BIN
app/alembic/__pycache__/env.cpython-312.pyc
Normal file
Binary file not shown.
85
app/alembic/env.py
Normal file
85
app/alembic/env.py
Normal 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
0
app/app/__init__.py
Normal file
BIN
app/app/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/app/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/__pycache__/main.cpython-312.pyc
Normal file
BIN
app/app/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
0
app/app/api/auth/__init__.py
Normal file
0
app/app/api/auth/__init__.py
Normal file
BIN
app/app/api/auth/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/app/api/auth/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/api/auth/__pycache__/router.cpython-312.pyc
Normal file
BIN
app/app/api/auth/__pycache__/router.cpython-312.pyc
Normal file
Binary file not shown.
89
app/app/api/auth/router.py
Normal file
89
app/app/api/auth/router.py
Normal 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"}
|
0
app/app/api/chores/__init__.py
Normal file
0
app/app/api/chores/__init__.py
Normal file
BIN
app/app/api/chores/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/app/api/chores/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/api/chores/__pycache__/router.cpython-312.pyc
Normal file
BIN
app/app/api/chores/__pycache__/router.cpython-312.pyc
Normal file
Binary file not shown.
260
app/app/api/chores/router.py
Normal file
260
app/app/api/chores/router.py
Normal 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
|
0
app/app/api/expenses/__init__.py
Normal file
0
app/app/api/expenses/__init__.py
Normal file
BIN
app/app/api/expenses/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/app/api/expenses/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/api/expenses/__pycache__/router.cpython-312.pyc
Normal file
BIN
app/app/api/expenses/__pycache__/router.cpython-312.pyc
Normal file
Binary file not shown.
320
app/app/api/expenses/router.py
Normal file
320
app/app/api/expenses/router.py
Normal 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()},
|
||||
)
|
0
app/app/api/houses/__init__.py
Normal file
0
app/app/api/houses/__init__.py
Normal file
BIN
app/app/api/houses/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/app/api/houses/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/api/houses/__pycache__/router.cpython-312.pyc
Normal file
BIN
app/app/api/houses/__pycache__/router.cpython-312.pyc
Normal file
Binary file not shown.
478
app/app/api/houses/router.py
Normal file
478
app/app/api/houses/router.py
Normal 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
|
0
app/app/api/ocr/__init__.py
Normal file
0
app/app/api/ocr/__init__.py
Normal file
BIN
app/app/api/ocr/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/app/api/ocr/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/api/ocr/__pycache__/router.cpython-312.pyc
Normal file
BIN
app/app/api/ocr/__pycache__/router.cpython-312.pyc
Normal file
Binary file not shown.
109
app/app/api/ocr/router.py
Normal file
109
app/app/api/ocr/router.py
Normal 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,
|
||||
}
|
0
app/app/api/shopping_lists/__init__.py
Normal file
0
app/app/api/shopping_lists/__init__.py
Normal file
BIN
app/app/api/shopping_lists/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/app/api/shopping_lists/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/api/shopping_lists/__pycache__/router.cpython-312.pyc
Normal file
BIN
app/app/api/shopping_lists/__pycache__/router.cpython-312.pyc
Normal file
Binary file not shown.
512
app/app/api/shopping_lists/router.py
Normal file
512
app/app/api/shopping_lists/router.py
Normal 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"}
|
0
app/app/api/users/__init__.py
Normal file
0
app/app/api/users/__init__.py
Normal file
BIN
app/app/api/users/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/app/api/users/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/api/users/__pycache__/router.cpython-312.pyc
Normal file
BIN
app/app/api/users/__pycache__/router.cpython-312.pyc
Normal file
Binary file not shown.
52
app/app/api/users/router.py
Normal file
52
app/app/api/users/router.py
Normal 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 user’s 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
0
app/app/core/__init__.py
Normal file
BIN
app/app/core/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/app/core/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/core/__pycache__/config.cpython-312.pyc
Normal file
BIN
app/app/core/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/core/__pycache__/dependencies.cpython-312.pyc
Normal file
BIN
app/app/core/__pycache__/dependencies.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/core/__pycache__/logger.cpython-312.pyc
Normal file
BIN
app/app/core/__pycache__/logger.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/core/__pycache__/security.cpython-312.pyc
Normal file
BIN
app/app/core/__pycache__/security.cpython-312.pyc
Normal file
Binary file not shown.
57
app/app/core/config.py
Normal file
57
app/app/core/config.py
Normal 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()
|
63
app/app/core/dependencies.py
Normal file
63
app/app/core/dependencies.py
Normal 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
64
app/app/core/logger.py
Normal 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
34
app/app/core/security.py
Normal 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
0
app/app/db/__init__.py
Normal file
BIN
app/app/db/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/app/db/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/db/__pycache__/base.cpython-312.pyc
Normal file
BIN
app/app/db/__pycache__/base.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/db/__pycache__/session.cpython-312.pyc
Normal file
BIN
app/app/db/__pycache__/session.cpython-312.pyc
Normal file
Binary file not shown.
17
app/app/db/base.py
Normal file
17
app/app/db/base.py
Normal 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
0
app/app/db/init_db.py
Normal file
18
app/app/db/session.py
Normal file
18
app/app/db/session.py
Normal 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
66
app/app/main.py
Normal 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"}
|
0
app/app/models/__init__.py
Normal file
0
app/app/models/__init__.py
Normal file
BIN
app/app/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/app/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/models/__pycache__/chore.cpython-312.pyc
Normal file
BIN
app/app/models/__pycache__/chore.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/models/__pycache__/expense.cpython-312.pyc
Normal file
BIN
app/app/models/__pycache__/expense.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/models/__pycache__/house.cpython-312.pyc
Normal file
BIN
app/app/models/__pycache__/house.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/models/__pycache__/invite.cpython-312.pyc
Normal file
BIN
app/app/models/__pycache__/invite.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/models/__pycache__/shopping_list.cpython-312.pyc
Normal file
BIN
app/app/models/__pycache__/shopping_list.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/models/__pycache__/user.cpython-312.pyc
Normal file
BIN
app/app/models/__pycache__/user.cpython-312.pyc
Normal file
Binary file not shown.
27
app/app/models/chore.py
Normal file
27
app/app/models/chore.py
Normal 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
40
app/app/models/expense.py
Normal 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
55
app/app/models/house.py
Normal 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
25
app/app/models/invite.py
Normal 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")
|
53
app/app/models/shopping_list.py
Normal file
53
app/app/models/shopping_list.py
Normal 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
19
app/app/models/user.py
Normal 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)
|
0
app/app/schemas/__init__.py
Normal file
0
app/app/schemas/__init__.py
Normal file
BIN
app/app/schemas/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/app/schemas/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/schemas/__pycache__/chore.cpython-312.pyc
Normal file
BIN
app/app/schemas/__pycache__/chore.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/schemas/__pycache__/expense.cpython-312.pyc
Normal file
BIN
app/app/schemas/__pycache__/expense.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/schemas/__pycache__/house.cpython-312.pyc
Normal file
BIN
app/app/schemas/__pycache__/house.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/schemas/__pycache__/invite.cpython-312.pyc
Normal file
BIN
app/app/schemas/__pycache__/invite.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/schemas/__pycache__/shopping_list.cpython-312.pyc
Normal file
BIN
app/app/schemas/__pycache__/shopping_list.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/schemas/__pycache__/user.cpython-312.pyc
Normal file
BIN
app/app/schemas/__pycache__/user.cpython-312.pyc
Normal file
Binary file not shown.
42
app/app/schemas/chore.py
Normal file
42
app/app/schemas/chore.py
Normal 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
|
57
app/app/schemas/expense.py
Normal file
57
app/app/schemas/expense.py
Normal 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
62
app/app/schemas/house.py
Normal 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
26
app/app/schemas/invite.py
Normal 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
|
66
app/app/schemas/shopping_list.py
Normal file
66
app/app/schemas/shopping_list.py
Normal 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
37
app/app/schemas/user.py
Normal 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
|
0
app/app/services/__init__.py
Normal file
0
app/app/services/__init__.py
Normal file
BIN
app/app/services/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/app/services/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/services/__pycache__/invite_service.cpython-312.pyc
Normal file
BIN
app/app/services/__pycache__/invite_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/app/services/__pycache__/ocr_service.cpython-312.pyc
Normal file
BIN
app/app/services/__pycache__/ocr_service.cpython-312.pyc
Normal file
Binary file not shown.
0
app/app/services/auth_service.py
Normal file
0
app/app/services/auth_service.py
Normal file
0
app/app/services/chore_service.py
Normal file
0
app/app/services/chore_service.py
Normal file
0
app/app/services/db_service.py
Normal file
0
app/app/services/db_service.py
Normal file
0
app/app/services/expense_service.py
Normal file
0
app/app/services/expense_service.py
Normal file
16
app/app/services/invite_service.py
Normal file
16
app/app/services/invite_service.py
Normal 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)}"
|
89
app/app/services/ocr_service.py
Normal file
89
app/app/services/ocr_service.py
Normal 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
31
app/docker-compose.yml
Normal 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
38
app/household_api.log
Normal 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
27
app/pyproject.toml
Normal 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
50
app/tests/api/auth.py
Normal 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"
|
109
app/tests/api/shopping_lists.py
Normal file
109
app/tests/api/shopping_lists.py
Normal 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
0
app/tests/conftest.py
Normal file
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user