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