Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ __pycache__
.git
.pytest_cache
.mypy_cache
.ruff_cache
.ruff_cache
.qwen/
3 changes: 2 additions & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ S3_HOST=s3-fairdrop
S3_PORT=9000
MINIO_ROOT_USER=s3_fairdrop_user
MINIO_ROOT_PASSWORD=s3_fairdrop_password
MINIO_BUCKET_NAME=s3_fairdrop-media
MINIO_BUCKET_NAME=fairdrop-media
MINIO_URL=http://${S3_HOST}:${S3_PORT}
S3_PUBLIC_URL=http://localhost:9000
PRESIGNED_URL_EXPIRE_SECONDS=3600
MIN_FILE_SIZE_BYTES=1
MAX_FILE_SIZE_BYTES=5242880
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ wheels/
.vscode/

# Local env
.coverage
.coverage
.qwen/
3 changes: 3 additions & 0 deletions app/core/auth_schemes.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/api/v1/auth/token')
oauth2_scheme_optional = OAuth2PasswordBearer(
tokenUrl='/api/v1/auth/token', auto_error=False
)
header_scheme = APIKeyHeader(name='X-API-Key', auto_error=False)
1 change: 1 addition & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class Settings(BaseSettings):
minio_root_password: str = Field(alias='MINIO_ROOT_PASSWORD')
minio_bucket_name: str = Field(alias='MINIO_BUCKET_NAME')
minio_url: str = Field(alias='MINIO_URL')
s3_public_url: str = Field(alias='S3_PUBLIC_URL')
pool_size: int = Field(alias='POOL_SIZE')
max_overflow: int = Field(alias='MAX_OVERFLOW')
jwt_algorithm: str = Field(default='HS256', alias='JWT_ALGORITHM')
Expand Down
12 changes: 9 additions & 3 deletions app/core/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,33 @@

import aioboto3 # type: ignore
import structlog
from botocore.config import Config
from botocore.exceptions import ClientError

from app.core.config import settings

logger = structlog.get_logger(__name__)

session = aioboto3.Session()
s3_config = Config(s3={'addressing_style': 'path'})


@asynccontextmanager
async def get_s3_client() -> AsyncIterator[Any]:
async def get_s3_client_gen() -> AsyncIterator[Any]:
async with session.client(
's3',
endpoint_url=settings.minio_url,
region_name='us-east-1',
aws_access_key_id=settings.minio_root_user,
aws_secret_access_key=settings.minio_root_password,
verify=False,
config=s3_config,
) as client:
yield client


get_s3_client = asynccontextmanager(get_s3_client_gen)


async def init_s3_bucket() -> None:
async with session.client(
's3',
Expand All @@ -34,13 +39,14 @@ async def init_s3_bucket() -> None:
aws_access_key_id=settings.minio_root_user,
aws_secret_access_key=settings.minio_root_password,
verify=False,
config=s3_config,
) as client:
try:
await client.head_bucket(Bucket=settings.minio_bucket_name)
logger.info(f'Bucket {settings.minio_bucket_name} already exists')
except ClientError as e:
if e.response['Error']['Code'] == '404':
await client.make_bucket(Bucket=settings.minio_bucket_name)
await client.create_bucket(Bucket=settings.minio_bucket_name)
logger.info(f'Bucket {settings.minio_bucket_name} created')
else:
logger.error(e)
23 changes: 20 additions & 3 deletions app/services/inventory/routes.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from typing import Annotated
from typing import Annotated, Any
from uuid import UUID

from fastapi import APIRouter, Depends, Header, Query, Request, status

from app.core.config import settings
from app.core.database import SessionDep
from app.core.s3 import get_s3_client_gen
from app.core.security import RoleChecker, UserRole
from app.services.inventory.models import ProductStatus
from app.services.inventory.schemas import (
Expand All @@ -15,6 +16,7 @@
ReservationResponse,
)
from app.services.inventory.service import InventoryAdminService, InventoryService
from app.services.media.service import generate_presigned_get_url
from app.services.user.models import User
from app.shared.decorators import idempotent
from app.shared.deps import get_current_user
Expand Down Expand Up @@ -50,10 +52,24 @@
)


async def _enrich_product_images(product: Any, s3_client: Any) -> ProductRead:
"""Helper to generate presigned URLs for product images."""
read_obj = ProductRead.model_validate(product)
image_urls = []
if hasattr(product, 'images'):
for img in product.images:
if img.status == 'active':
url = await generate_presigned_get_url(s3_client, img.file_path)
image_urls.append(url)
read_obj.image_urls = image_urls
return read_obj


@router_v1.get('/', response_model=list[ProductRead])
async def get_active_products(
session: SessionDep,
service: Annotated[InventoryService, Depends(get_inventory_service)],
s3_client: Any = Depends(get_s3_client_gen),
skip: int = 0,
limit: int = 50,
) -> list[ProductRead]:
Expand All @@ -63,7 +79,7 @@ async def get_active_products(
limit=limit,
session=session,
)
return [ProductRead.model_validate(p) for p in products]
return [await _enrich_product_images(p, s3_client) for p in products]


@router_v1.post('/', response_model=ProductRead, status_code=status.HTTP_201_CREATED)
Expand Down Expand Up @@ -134,12 +150,13 @@ async def get_product(
product_id: UUID,
session: SessionDep,
service: Annotated[InventoryService, Depends(get_inventory_service)],
s3_client: Any = Depends(get_s3_client_gen),
) -> ProductRead:
product = await service.get_product(
session=session,
product_id=product_id,
)
return ProductRead.model_validate(product)
return await _enrich_product_images(product, s3_client)


@router_v1.post('/reserve', response_model=ReservationResponse)
Expand Down
1 change: 1 addition & 0 deletions app/services/inventory/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class ProductRead(BaseModel):
status: ProductStatus
created_at: datetime
updated_at: datetime
image_urls: list[str] = []


class ReservationCreate(BaseModel):
Expand Down
17 changes: 14 additions & 3 deletions app/services/inventory/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload

from app.core.audit_log.service import audit_log_service
from app.core.config import settings
Expand Down Expand Up @@ -53,8 +54,10 @@ async def _get_product(
query = select(Product).where(Product.id == product_id)
if for_update:
query = query.with_for_update()
else:
query = query.options(joinedload(Product.images))
result = await session.execute(query)
product = result.scalar_one_or_none()
product = result.unique().scalar_one_or_none()
if not product:
raise NotFoundError
if current_user:
Expand Down Expand Up @@ -142,9 +145,17 @@ async def update_product(
product = await InventoryService._get_product(
session, product_id, for_update=True, current_user=current_user
)
if product.status in (
ProductStatus.PENDING_MODERATION,
ProductStatus.MODERATION_IN_PROGRESS,
):
raise ConflictError('Cannot edit product while it is under moderation')
old_snapshot = ProductRead.model_validate(product)
for field, value in product_data.model_dump(exclude_unset=True).items():
setattr(product, field, value)
if product.status == ProductStatus.ACTIVE:
product.status = ProductStatus.PENDING_MODERATION
product.moderator_id = None
await InventoryService._log_product_change(
session=session,
user=current_user,
Expand Down Expand Up @@ -187,11 +198,11 @@ async def get_products(
skip: int = 0,
limit: int = 50,
) -> list[Product]:
query = select(Product)
query = select(Product).options(joinedload(Product.images))
if status:
query = query.where(Product.status == status)
result = await session.execute(query.offset(skip).limit(limit))
return list(result.scalars().all())
return list(result.scalars().unique().all())

@staticmethod
async def reserve_items(
Expand Down
10 changes: 5 additions & 5 deletions app/services/media/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from app.core.audit_log.service import audit_log_service
from app.core.database import get_session
from app.core.s3 import get_s3_client
from app.core.s3 import get_s3_client_gen
from app.services.media.schemas import (
ImageUploadRequest,
ImageUploadResponse,
Expand All @@ -21,7 +21,7 @@
handle_minio_webhook,
)
from app.services.user.models import User, UserRole
from app.shared.deps import get_current_user
from app.shared.deps import get_current_user, get_current_user_flexible

router_v1 = APIRouter(prefix='/media', tags=['Media'])

Expand All @@ -31,7 +31,7 @@ async def create_upload_url(
product_id: UUID,
req: ImageUploadRequest,
session: AsyncSession = Depends(get_session),
s3_client: Any = Depends(get_s3_client),
s3_client: Any = Depends(get_s3_client_gen),
current_user: User = Depends(get_current_user),
) -> ImageUploadResponse:
return await generate_upload_url(session, s3_client, product_id, req)
Expand All @@ -53,8 +53,8 @@ async def view_private_file(
target_id: UUID,
doc_key: str | None = None,
session: AsyncSession = Depends(get_session),
s3_client: Any = Depends(get_s3_client),
current_user: User = Depends(get_current_user),
s3_client: Any = Depends(get_s3_client_gen),
current_user: User = Depends(get_current_user_flexible),
) -> RedirectResponse:
if current_user.role not in (UserRole.ADMIN, UserRole.MODERATOR):
raise HTTPException(
Expand Down
36 changes: 34 additions & 2 deletions app/services/media/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,47 @@ async def generate_presigned_get_url(
key: str,
expires_in: int = 3600,
) -> str:
"""Generates a presigned GET URL for reading private files."""
return cast(
"""
Generates a presigned GET URL for reading private files with host substitution.
"""
logger.debug(
'generating s3 url',
input_key=key,
current_bucket=settings.minio_bucket_name,
)
original_key = key
if '://' in key:
parts = key.split('/', 3)
if len(parts) >= 4:
key = parts[3]
bucket_candidates = [
settings.minio_bucket_name,
's3_fairdrop-media',
's3-fairdrop-media',
]
while True:
key = key.lstrip('/')
stripped = False
for b in bucket_candidates:
if key.startswith(f'{b}/'):
key = key[len(b) + 1 :]
stripped = True
if not stripped:
break
key = key.lstrip('/')
if key != original_key:
logger.debug('sanitized s3 key', original=original_key, sanitized=key)
url = cast(
str,
await s3_client.generate_presigned_url(
'get_object',
Params={'Bucket': settings.minio_bucket_name, 'Key': key},
ExpiresIn=expires_in,
),
)
if settings.minio_url != settings.s3_public_url:
url = url.replace(settings.minio_url, settings.s3_public_url)
return url


async def get_secure_file_path(
Expand Down
30 changes: 29 additions & 1 deletion app/services/user/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Depends, Request, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select

Expand Down Expand Up @@ -134,3 +134,31 @@ async def create_upgrade_request(
docs_url=schema.docs_url,
)
return VerificationRequestRead.model_validate(verification_request)


@router_v1.get(
'/users/me/upgrade-requests', response_model=list[VerificationRequestRead]
)
async def get_upgrade_requests(
current_user: Annotated[User, Depends(get_current_user)],
session: SessionDep,
) -> list[VerificationRequestRead]:
requests = await UserService.get_verification_requests(session, current_user.id)
return [VerificationRequestRead.model_validate(req) for req in requests]


@router_v1.get(
'/users/me/upgrade-requests/latest', response_model=VerificationRequestRead
)
async def get_latest_upgrade_request(
current_user: Annotated[User, Depends(get_current_user)],
session: SessionDep,
) -> VerificationRequestRead:
verification_request = await UserService.get_latest_verification_request(
session, current_user.id
)
if not verification_request:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail='No upgrade requests found'
)
return VerificationRequestRead.model_validate(verification_request)
5 changes: 4 additions & 1 deletion app/services/user/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,15 @@ class APIKeyWithSecret(APIKeyRead):


class VerificationRequestCreate(BaseModel):
target_role: Literal[UserRole.USER_B2B, UserRole.SELLER_B2B]
target_role: Literal[UserRole.USER_B2B, UserRole.SELLER_B2B, UserRole.SELLER]
docs_url: dict[str, str] | None = None


class VerificationRequestRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
target_role: UserRole
status: VerificationStatus
admin_feedback: str | None = None
created_at: datetime
updated_at: datetime
Loading