inspirations
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional, BinaryIO
|
||||
from typing import Optional, BinaryIO, AsyncGenerator
|
||||
import aioboto3
|
||||
from botocore.exceptions import ClientError
|
||||
import os
|
||||
@@ -56,11 +56,25 @@ class S3Adapter:
|
||||
print(f"Error downloading from S3: {e}")
|
||||
return None
|
||||
|
||||
async def stream_file(self, object_name: str, chunk_size: int = 65536):
|
||||
async def get_file_size(self, object_name: str) -> Optional[int]:
|
||||
"""Returns the size of the file in bytes."""
|
||||
try:
|
||||
async with self._get_client() as client:
|
||||
response = await client.head_object(Bucket=self.bucket_name, Key=object_name)
|
||||
return response['ContentLength']
|
||||
except ClientError as e:
|
||||
print(f"Error getting file size from S3: {e}")
|
||||
return None
|
||||
|
||||
async def stream_file(self, object_name: str, range_header: Optional[str] = None, chunk_size: int = 65536) -> AsyncGenerator[bytes, None]:
|
||||
"""Streams a file from S3 yielding chunks. Memory-efficient for large files."""
|
||||
try:
|
||||
async with self._get_client() as client:
|
||||
response = await client.get_object(Bucket=self.bucket_name, Key=object_name)
|
||||
args = {'Bucket': self.bucket_name, 'Key': object_name}
|
||||
if range_header:
|
||||
args['Range'] = range_header
|
||||
|
||||
response = await client.get_object(**args)
|
||||
# aioboto3 Body is an aiohttp StreamReader wrapper
|
||||
body = response['Body']
|
||||
|
||||
|
||||
2
aiws.py
2
aiws.py
@@ -45,6 +45,7 @@ from api.endpoints.project_router import router as project_api_router
|
||||
from api.endpoints.idea_router import router as idea_api_router
|
||||
from api.endpoints.post_router import router as post_api_router
|
||||
from api.endpoints.environment_router import router as environment_api_router
|
||||
from api.endpoints.inspiration_router import router as inspiration_api_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -222,6 +223,7 @@ app.include_router(project_api_router)
|
||||
app.include_router(idea_api_router)
|
||||
app.include_router(post_api_router)
|
||||
app.include_router(environment_api_router)
|
||||
app.include_router(inspiration_api_router)
|
||||
|
||||
# Prometheus Metrics (Instrument after all routers are added)
|
||||
Instrumentator(
|
||||
|
||||
@@ -62,4 +62,9 @@ async def get_album_service(dao: DAO = Depends(get_dao)) -> AlbumService:
|
||||
from api.service.post_service import PostService
|
||||
|
||||
def get_post_service(dao: DAO = Depends(get_dao)) -> PostService:
|
||||
return PostService(dao)
|
||||
return PostService(dao)
|
||||
|
||||
from api.service.inspiration_service import InspirationService
|
||||
|
||||
def get_inspiration_service(dao: DAO = Depends(get_dao), s3_adapter: S3Adapter = Depends(get_s3_adapter)) -> InspirationService:
|
||||
return InspirationService(dao, s3_adapter)
|
||||
|
||||
@@ -42,8 +42,9 @@ async def get_asset(
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
headers = {
|
||||
"Cache-Control": "public, max-age=31536000, immutable"
|
||||
base_headers = {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Accept-Ranges": "bytes"
|
||||
}
|
||||
|
||||
# Thumbnail: маленький, можно грузить в RAM
|
||||
@@ -51,17 +52,70 @@ async def get_asset(
|
||||
if asset.minio_thumbnail_object_name and s3_adapter:
|
||||
thumb_bytes = await s3_adapter.get_file(asset.minio_thumbnail_object_name)
|
||||
if thumb_bytes:
|
||||
return Response(content=thumb_bytes, media_type="image/jpeg", headers=headers)
|
||||
return Response(content=thumb_bytes, media_type="image/jpeg", headers=base_headers)
|
||||
# Fallback: thumbnail in DB
|
||||
if asset.thumbnail:
|
||||
return Response(content=asset.thumbnail, media_type="image/jpeg", headers=headers)
|
||||
return Response(content=asset.thumbnail, media_type="image/jpeg", headers=base_headers)
|
||||
# No thumbnail available — fall through to main content
|
||||
|
||||
# Main content: стримим из S3 без загрузки в RAM
|
||||
if asset.minio_object_name and s3_adapter:
|
||||
content_type = "image/png"
|
||||
# if asset.content_type == AssetContentType.VIDEO:
|
||||
# content_type = "video/mp4"
|
||||
if asset.content_type == AssetContentType.VIDEO:
|
||||
content_type = "video/mp4" # Or detect from extension if stored
|
||||
elif asset.content_type == AssetContentType.IMAGE:
|
||||
content_type = "image/png" # Default for images
|
||||
|
||||
# Better content type detection based on extension if possible, but for now this is okay
|
||||
if asset.minio_object_name.endswith(".mp4"):
|
||||
content_type = "video/mp4"
|
||||
elif asset.minio_object_name.endswith(".mov"):
|
||||
content_type = "video/quicktime"
|
||||
elif asset.minio_object_name.endswith(".jpg") or asset.minio_object_name.endswith(".jpeg"):
|
||||
content_type = "image/jpeg"
|
||||
|
||||
# Handle Range requests for video streaming
|
||||
range_header = request.headers.get("range")
|
||||
file_size = await s3_adapter.get_file_size(asset.minio_object_name)
|
||||
|
||||
if range_header and file_size:
|
||||
try:
|
||||
# Parse Range header: bytes=start-end
|
||||
byte_range = range_header.replace("bytes=", "")
|
||||
start_str, end_str = byte_range.split("-")
|
||||
start = int(start_str)
|
||||
end = int(end_str) if end_str else file_size - 1
|
||||
|
||||
# Validate range
|
||||
if start >= file_size:
|
||||
# 416 Range Not Satisfiable
|
||||
return Response(status_code=416, headers={"Content-Range": f"bytes */{file_size}"})
|
||||
|
||||
chunk_size = end - start + 1
|
||||
|
||||
headers = base_headers.copy()
|
||||
headers.update({
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
"Content-Length": str(chunk_size),
|
||||
})
|
||||
|
||||
# Pass the exact range string to S3
|
||||
s3_range = f"bytes={start}-{end}"
|
||||
|
||||
return StreamingResponse(
|
||||
s3_adapter.stream_file(asset.minio_object_name, range_header=s3_range),
|
||||
status_code=206,
|
||||
headers=headers,
|
||||
media_type=content_type
|
||||
)
|
||||
except ValueError:
|
||||
pass # Fallback to full content if range parsing fails
|
||||
|
||||
# Full content response
|
||||
headers = base_headers.copy()
|
||||
if file_size:
|
||||
headers["Content-Length"] = str(file_size)
|
||||
|
||||
return StreamingResponse(
|
||||
s3_adapter.stream_file(asset.minio_object_name),
|
||||
media_type=content_type,
|
||||
@@ -70,7 +124,7 @@ async def get_asset(
|
||||
|
||||
# Fallback: data stored in DB (legacy)
|
||||
if asset.data:
|
||||
return Response(content=asset.data, media_type="image/png", headers=headers)
|
||||
return Response(content=asset.data, media_type="image/png", headers=base_headers)
|
||||
|
||||
raise HTTPException(status_code=404, detail="Asset data not found")
|
||||
|
||||
|
||||
@@ -20,7 +20,13 @@ async def create_idea(
|
||||
):
|
||||
pid = project_id or request.project_id
|
||||
|
||||
return await idea_service.create_idea(request.name, request.description, pid, str(current_user["_id"]))
|
||||
return await idea_service.create_idea(
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
project_id=pid,
|
||||
user_id=str(current_user["_id"]),
|
||||
inspiration_id=request.inspiration_id
|
||||
)
|
||||
|
||||
@router.get("", response_model=List[IdeaResponse])
|
||||
async def get_ideas(
|
||||
@@ -48,7 +54,12 @@ async def update_idea(
|
||||
request: IdeaUpdateRequest,
|
||||
idea_service: IdeaService = Depends(get_idea_service)
|
||||
):
|
||||
idea = await idea_service.update_idea(idea_id, request.name, request.description)
|
||||
idea = await idea_service.update_idea(
|
||||
idea_id=idea_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
inspiration_id=request.inspiration_id
|
||||
)
|
||||
if not idea:
|
||||
raise HTTPException(status_code=404, detail="Idea not found")
|
||||
return idea
|
||||
|
||||
95
api/endpoints/inspiration_router.py
Normal file
95
api/endpoints/inspiration_router.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from api.dependency import get_inspiration_service, get_project_id
|
||||
from api.endpoints.auth import get_current_user
|
||||
from api.models.InspirationRequest import InspirationCreateRequest, InspirationResponse, InspirationListResponse
|
||||
from api.service.inspiration_service import InspirationService
|
||||
from models.Inspiration import Inspiration
|
||||
|
||||
router = APIRouter(prefix="/api/inspirations", tags=["Inspirations"])
|
||||
|
||||
|
||||
@router.post("", response_model=InspirationResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_inspiration(
|
||||
request: InspirationCreateRequest,
|
||||
project_id: Optional[str] = Depends(get_project_id),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
service: InspirationService = Depends(get_inspiration_service)
|
||||
):
|
||||
pid = project_id or request.project_id
|
||||
|
||||
inspiration = await service.create_inspiration(
|
||||
source_url=request.source_url,
|
||||
created_by=str(current_user["_id"]),
|
||||
project_id=pid,
|
||||
caption=request.caption
|
||||
)
|
||||
return inspiration
|
||||
|
||||
|
||||
@router.get("", response_model=InspirationListResponse)
|
||||
async def get_inspirations(
|
||||
project_id: Optional[str] = Depends(get_project_id),
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
service: InspirationService = Depends(get_inspiration_service)
|
||||
):
|
||||
# If project_id is provided, filter by it. Otherwise, filter by user.
|
||||
# Or maybe we want to see all user's inspirations if no project is selected?
|
||||
# Let's follow the pattern: if project_id is present, show project's inspirations.
|
||||
# If not, show user's personal inspirations (where project_id is None) OR all user's inspirations?
|
||||
# Usually "My Inspirations" means created by me.
|
||||
|
||||
# Let's assume:
|
||||
# If project_id -> filter by project_id (and maybe created_by if we want strict ownership, but usually project members share)
|
||||
# If no project_id -> filter by created_by (personal)
|
||||
|
||||
pid = project_id
|
||||
uid = str(current_user["_id"])
|
||||
|
||||
inspirations = await service.get_inspirations(project_id=pid, created_by=uid if not pid else None, limit=limit, offset=offset)
|
||||
total_count = await service.dao.inspirations.count_inspirations(project_id=pid, created_by=uid if not pid else None)
|
||||
|
||||
return InspirationListResponse(
|
||||
inspirations=[InspirationResponse(**inspiration.model_dump()) for inspiration in inspirations],
|
||||
total_count=total_count
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{inspiration_id}", response_model=InspirationResponse)
|
||||
async def get_inspiration(
|
||||
inspiration_id: str,
|
||||
service: InspirationService = Depends(get_inspiration_service),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
inspiration = await service.get_inspiration(inspiration_id)
|
||||
if not inspiration:
|
||||
raise HTTPException(status_code=404, detail="Inspiration not found")
|
||||
return inspiration
|
||||
|
||||
|
||||
@router.patch("/{inspiration_id}/complete", response_model=InspirationResponse)
|
||||
async def mark_inspiration_complete(
|
||||
inspiration_id: str,
|
||||
is_completed: bool = True,
|
||||
service: InspirationService = Depends(get_inspiration_service),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
inspiration = await service.mark_as_completed(inspiration_id, is_completed)
|
||||
if not inspiration:
|
||||
raise HTTPException(status_code=404, detail="Inspiration not found")
|
||||
return inspiration
|
||||
|
||||
|
||||
@router.delete("/{inspiration_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_inspiration(
|
||||
inspiration_id: str,
|
||||
service: InspirationService = Depends(get_inspiration_service),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
success = await service.delete_inspiration(inspiration_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Inspiration not found")
|
||||
return None
|
||||
@@ -7,10 +7,12 @@ class IdeaCreateRequest(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
project_id: Optional[str] = None # Optional in body if passed via header/dependency
|
||||
inspiration_id: Optional[str] = None
|
||||
|
||||
class IdeaUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
inspiration_id: Optional[str] = None
|
||||
|
||||
class IdeaResponse(Idea):
|
||||
last_generation: Optional[GenerationResponse] = None
|
||||
|
||||
29
api/models/InspirationRequest.py
Normal file
29
api/models/InspirationRequest.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from models.Inspiration import Inspiration
|
||||
|
||||
|
||||
class InspirationCreateRequest(BaseModel):
|
||||
source_url: str
|
||||
caption: Optional[str] = None
|
||||
project_id: Optional[str] = None
|
||||
|
||||
|
||||
class InspirationResponse(BaseModel):
|
||||
id: str
|
||||
source_url: str
|
||||
caption: str | None = None
|
||||
asset_id: str
|
||||
is_completed: bool
|
||||
created_by: str
|
||||
project_id: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class InspirationListResponse(BaseModel):
|
||||
inspirations: List[InspirationResponse]
|
||||
total_count: int
|
||||
@@ -7,8 +7,14 @@ class IdeaService:
|
||||
def __init__(self, dao: DAO):
|
||||
self.dao = dao
|
||||
|
||||
async def create_idea(self, name: str, description: Optional[str], project_id: Optional[str], user_id: str) -> Idea:
|
||||
idea = Idea(name=name, description=description, project_id=project_id, created_by=user_id)
|
||||
async def create_idea(self, name: str, description: Optional[str], project_id: Optional[str], user_id: str, inspiration_id: Optional[str] = None) -> Idea:
|
||||
idea = Idea(
|
||||
name=name,
|
||||
description=description,
|
||||
project_id=project_id,
|
||||
created_by=user_id,
|
||||
inspiration_id=inspiration_id
|
||||
)
|
||||
idea_id = await self.dao.ideas.create_idea(idea)
|
||||
idea.id = idea_id
|
||||
return idea
|
||||
@@ -19,7 +25,7 @@ class IdeaService:
|
||||
async def get_idea(self, idea_id: str) -> Optional[Idea]:
|
||||
return await self.dao.ideas.get_idea(idea_id)
|
||||
|
||||
async def update_idea(self, idea_id: str, name: Optional[str] = None, description: Optional[str] = None) -> Optional[Idea]:
|
||||
async def update_idea(self, idea_id: str, name: Optional[str] = None, description: Optional[str] = None, inspiration_id: Optional[str] = None) -> Optional[Idea]:
|
||||
idea = await self.dao.ideas.get_idea(idea_id)
|
||||
if not idea:
|
||||
return None
|
||||
@@ -28,6 +34,8 @@ class IdeaService:
|
||||
idea.name = name
|
||||
if description is not None:
|
||||
idea.description = description
|
||||
if inspiration_id is not None:
|
||||
idea.inspiration_id = inspiration_id
|
||||
|
||||
idea.updated_at = datetime.now()
|
||||
await self.dao.ideas.update_idea(idea)
|
||||
@@ -72,4 +80,3 @@ class IdeaService:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
146
api/service/inspiration_service.py
Normal file
146
api/service/inspiration_service.py
Normal file
@@ -0,0 +1,146 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from models.Asset import Asset, AssetType, AssetContentType
|
||||
from models.Inspiration import Inspiration
|
||||
from repos.dao import DAO
|
||||
from adapters.s3_adapter import S3Adapter
|
||||
|
||||
# Try to import yt_dlp, but don't crash if it's missing (though we added it to requirements)
|
||||
try:
|
||||
import yt_dlp
|
||||
except ImportError:
|
||||
yt_dlp = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class InspirationService:
|
||||
def __init__(self, dao: DAO, s3_adapter: S3Adapter):
|
||||
self.dao = dao
|
||||
self.s3_adapter = s3_adapter
|
||||
|
||||
async def create_inspiration(self, source_url: str, created_by: str, project_id: Optional[str] = None, caption: Optional[str] = None) -> Inspiration:
|
||||
# 1. Download content from Instagram
|
||||
try:
|
||||
content_bytes, content_type, ext = await self._download_content(source_url)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download content from {source_url}: {e}")
|
||||
raise HTTPException(status_code=400, detail=f"Failed to download content: {str(e)}")
|
||||
|
||||
# 2. Save as Asset
|
||||
filename = f"inspirations/{datetime.now().strftime('%Y%m%d_%H%M%S')}_insta.{ext}"
|
||||
|
||||
await self.s3_adapter.upload_file(filename, content_bytes, content_type=content_type)
|
||||
|
||||
asset = Asset(
|
||||
name=f"Inspiration from {source_url}",
|
||||
type=AssetType.INSPIRATION,
|
||||
content_type=AssetContentType.VIDEO if content_type.startswith("video") else AssetContentType.IMAGE,
|
||||
minio_object_name=filename,
|
||||
minio_bucket=self.s3_adapter.bucket_name,
|
||||
created_by=created_by,
|
||||
project_id=project_id
|
||||
)
|
||||
asset_id = await self.dao.assets.create_asset(asset)
|
||||
|
||||
# 3. Create Inspiration object
|
||||
inspiration = Inspiration(
|
||||
source_url=source_url,
|
||||
caption=caption,
|
||||
asset_id=str(asset_id),
|
||||
created_by=created_by,
|
||||
project_id=project_id
|
||||
)
|
||||
insp_id = await self.dao.inspirations.create_inspiration(inspiration)
|
||||
inspiration.id = insp_id
|
||||
|
||||
return inspiration
|
||||
|
||||
async def get_inspirations(self, project_id: Optional[str], created_by: str, limit: int = 20, offset: int = 0) -> List[Inspiration]:
|
||||
return await self.dao.inspirations.get_inspirations(project_id, created_by, limit, offset)
|
||||
|
||||
async def get_inspiration(self, inspiration_id: str) -> Optional[Inspiration]:
|
||||
return await self.dao.inspirations.get_inspiration(inspiration_id)
|
||||
|
||||
async def mark_as_completed(self, inspiration_id: str, is_completed: bool = True) -> Optional[Inspiration]:
|
||||
inspiration = await self.dao.inspirations.get_inspiration(inspiration_id)
|
||||
if not inspiration:
|
||||
return None
|
||||
|
||||
inspiration.is_completed = is_completed
|
||||
inspiration.updated_at = datetime.now()
|
||||
await self.dao.inspirations.update_inspiration(inspiration)
|
||||
return inspiration
|
||||
|
||||
async def delete_inspiration(self, inspiration_id: str) -> bool:
|
||||
inspiration = await self.dao.inspirations.get_inspiration(inspiration_id)
|
||||
if not inspiration:
|
||||
return False
|
||||
|
||||
# Delete associated asset
|
||||
if inspiration.asset_id:
|
||||
await self.dao.assets.delete_asset(inspiration.asset_id)
|
||||
|
||||
return await self.dao.inspirations.delete_inspiration(inspiration_id)
|
||||
|
||||
async def _download_content(self, url: str) -> Tuple[bytes, str, str]:
|
||||
"""
|
||||
Downloads content using yt-dlp.
|
||||
Returns (content_bytes, content_type, extension)
|
||||
"""
|
||||
if not yt_dlp:
|
||||
raise RuntimeError("yt-dlp is not installed")
|
||||
|
||||
logger.info(f"Downloading from {url} using yt-dlp...")
|
||||
|
||||
def run_yt_dlp():
|
||||
with tempfile.TemporaryDirectory() as tmpdirname:
|
||||
ydl_opts = {
|
||||
'outtmpl': f'{tmpdirname}/%(id)s.%(ext)s',
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'format': 'best', # Best quality single file
|
||||
'noplaylist': True, # Only single video if it's a playlist/profile
|
||||
'writethumbnail': False,
|
||||
'writesubtitles': False,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
ydl.download([url])
|
||||
|
||||
# Find the downloaded file
|
||||
files = os.listdir(tmpdirname)
|
||||
if not files:
|
||||
raise Exception("No files downloaded")
|
||||
|
||||
# Pick the largest file if multiple (e.g. if yt-dlp downloaded parts)
|
||||
# But with 'format': 'best', it should be one.
|
||||
# If carousel, it might be multiple. Let's pick the first one.
|
||||
filename = files[0]
|
||||
filepath = os.path.join(tmpdirname, filename)
|
||||
|
||||
with open(filepath, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
ext = filename.split('.')[-1].lower()
|
||||
|
||||
# Determine content type
|
||||
if ext in ['mp4', 'mov', 'avi', 'mkv', 'webm']:
|
||||
content_type = f"video/{ext}"
|
||||
if ext == 'mov': content_type = "video/quicktime"
|
||||
elif ext in ['jpg', 'jpeg', 'png', 'webp']:
|
||||
content_type = f"image/{ext}"
|
||||
if ext == 'jpg': content_type = "image/jpeg"
|
||||
else:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
return data, content_type, ext
|
||||
|
||||
return await asyncio.to_thread(run_yt_dlp)
|
||||
@@ -8,10 +8,12 @@ from pydantic import BaseModel, computed_field, Field, model_validator
|
||||
class AssetContentType(str, Enum):
|
||||
IMAGE = 'image'
|
||||
PROMPT = 'prompt'
|
||||
VIDEO = 'video'
|
||||
|
||||
class AssetType(str, Enum):
|
||||
UPLOADED = 'uploaded'
|
||||
GENERATED = 'generated'
|
||||
INSPIRATION = 'inspiration'
|
||||
|
||||
|
||||
class Asset(BaseModel):
|
||||
|
||||
@@ -7,6 +7,7 @@ class Idea(BaseModel):
|
||||
name: str = "New Idea"
|
||||
description: Optional[str] = None
|
||||
project_id: Optional[str] = None
|
||||
inspiration_id: Optional[str] = None # Link to Inspiration
|
||||
created_by: str # User ID
|
||||
is_deleted: bool = False
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
|
||||
16
models/Inspiration.py
Normal file
16
models/Inspiration.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from datetime import datetime, UTC
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Inspiration(BaseModel):
|
||||
id: str | None = None
|
||||
source_url: str
|
||||
caption: str | None = None
|
||||
asset_id: str
|
||||
is_completed: bool = False
|
||||
created_by: str
|
||||
project_id: str | None = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
@@ -9,6 +9,7 @@ from repos.project_repo import ProjectRepo
|
||||
from repos.idea_repo import IdeaRepo
|
||||
from repos.post_repo import PostRepo
|
||||
from repos.environment_repo import EnvironmentRepo
|
||||
from repos.inspiration_repo import InspirationRepo
|
||||
|
||||
|
||||
from typing import Optional
|
||||
@@ -25,3 +26,4 @@ class DAO:
|
||||
self.ideas = IdeaRepo(client, db_name)
|
||||
self.posts = PostRepo(client, db_name)
|
||||
self.environments = EnvironmentRepo(client, db_name)
|
||||
self.inspirations = InspirationRepo(client, db_name)
|
||||
|
||||
54
repos/inspiration_repo.py
Normal file
54
repos/inspiration_repo.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from bson import ObjectId
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
|
||||
from models.Inspiration import Inspiration
|
||||
|
||||
|
||||
class InspirationRepo:
|
||||
def __init__(self, client: AsyncIOMotorClient, db_name="bot_db"):
|
||||
self.collection = client[db_name]["inspirations"]
|
||||
|
||||
async def create_inspiration(self, inspiration: Inspiration) -> str:
|
||||
res = await self.collection.insert_one(inspiration.model_dump(exclude={"id"}))
|
||||
return str(res.inserted_id)
|
||||
|
||||
async def get_inspiration(self, inspiration_id: str) -> Optional[Inspiration]:
|
||||
res = await self.collection.find_one({"_id": ObjectId(inspiration_id)})
|
||||
if res:
|
||||
res["id"] = str(res.pop("_id"))
|
||||
return Inspiration(**res)
|
||||
return None
|
||||
|
||||
async def get_inspirations(self, project_id: Optional[str] = None, created_by: Optional[str] = None, limit: int = 20, offset: int = 0) -> List[Inspiration]:
|
||||
query = {}
|
||||
if project_id:
|
||||
query["project_id"] = project_id
|
||||
if created_by:
|
||||
query["created_by"] = created_by
|
||||
|
||||
cursor = self.collection.find(query).sort("created_at", -1).skip(offset).limit(limit)
|
||||
inspirations = []
|
||||
async for doc in cursor:
|
||||
doc["id"] = str(doc.pop("_id"))
|
||||
inspirations.append(Inspiration(**doc))
|
||||
return inspirations
|
||||
|
||||
async def count_inspirations(self, project_id: Optional[str] = None, created_by: Optional[str] = None) -> int:
|
||||
query = {}
|
||||
if project_id:
|
||||
query["project_id"] = project_id
|
||||
if created_by:
|
||||
query["created_by"] = created_by
|
||||
return await self.collection.count_documents(query)
|
||||
|
||||
async def update_inspiration(self, inspiration: Inspiration):
|
||||
await self.collection.update_one(
|
||||
{"_id": ObjectId(inspiration.id)},
|
||||
{"$set": inspiration.model_dump(exclude={"id"})}
|
||||
)
|
||||
|
||||
async def delete_inspiration(self, inspiration_id: str) -> bool:
|
||||
res = await self.collection.delete_one({"_id": ObjectId(inspiration_id)})
|
||||
return res.deleted_count > 0
|
||||
@@ -52,3 +52,4 @@ python-multipart==0.0.22
|
||||
email-validator
|
||||
prometheus-fastapi-instrumentator
|
||||
pydantic-settings==2.13.0
|
||||
yt-dlp
|
||||
|
||||
Reference in New Issue
Block a user