feat: Enhance idea retrieval to include the latest generation and support user-specific ideas not tied to a project, while also improving asset storage uniqueness and adjusting generation cancellation timeout.

This commit is contained in:
xds
2026-02-16 16:35:26 +03:00
parent 5e7dc19bf3
commit 68a3f529cb
15 changed files with 67 additions and 30 deletions

View File

@@ -130,7 +130,7 @@ async def start_scheduler(service: GenerationService):
break break
except Exception as e: except Exception as e:
logger.error(f"Scheduler error: {e}") logger.error(f"Scheduler error: {e}")
await asyncio.sleep(600) # Check every 10 minutes await asyncio.sleep(60) # Check every 10 minutes
# --- LIFESPAN (Запуск FastAPI + Bot) --- # --- LIFESPAN (Запуск FastAPI + Bot) ---
@asynccontextmanager @asynccontextmanager

View File

@@ -6,34 +6,30 @@ from api.service.idea_service import IdeaService
from api.service.generation_service import GenerationService from api.service.generation_service import GenerationService
from models.Idea import Idea from models.Idea import Idea
from api.models.GenerationRequest import GenerationResponse, GenerationsResponse from api.models.GenerationRequest import GenerationResponse, GenerationsResponse
from api.models.IdeaRequest import IdeaCreateRequest, IdeaUpdateRequest from api.models.IdeaRequest import IdeaCreateRequest, IdeaUpdateRequest, IdeaResponse
router = APIRouter(prefix="/api/ideas", tags=["ideas"]) router = APIRouter(prefix="/api/ideas", tags=["ideas"])
@router.post("", response_model=Idea) @router.post("", response_model=Idea)
async def create_idea( async def create_idea(
request: IdeaCreateRequest, request: IdeaCreateRequest,
project_id: str = Depends(get_project_id), project_id: Optional[str] = Depends(get_project_id),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
idea_service: IdeaService = Depends(get_idea_service) idea_service: IdeaService = Depends(get_idea_service)
): ):
if not project_id and not request.project_id:
raise HTTPException(status_code=400, detail="Project ID header is required")
pid = project_id or request.project_id 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(request.name, request.description, pid, str(current_user["_id"]))
@router.get("", response_model=List[Idea]) @router.get("", response_model=List[IdeaResponse])
async def get_ideas( async def get_ideas(
project_id: str = Depends(get_project_id), project_id: Optional[str] = Depends(get_project_id),
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
current_user: dict = Depends(get_current_user),
idea_service: IdeaService = Depends(get_idea_service) idea_service: IdeaService = Depends(get_idea_service)
): ):
if not project_id: return await idea_service.get_ideas(project_id, str(current_user["_id"]), limit, offset)
raise HTTPException(status_code=400, detail="Project ID header is required")
return await idea_service.get_ideas(project_id, limit, offset)
@router.get("/{idea_id}", response_model=Idea) @router.get("/{idea_id}", response_model=Idea)
async def get_idea( async def get_idea(

View File

@@ -1,5 +1,7 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
from models.Idea import Idea
from api.models.GenerationRequest import GenerationResponse
class IdeaCreateRequest(BaseModel): class IdeaCreateRequest(BaseModel):
name: str name: str
@@ -9,3 +11,6 @@ class IdeaCreateRequest(BaseModel):
class IdeaUpdateRequest(BaseModel): class IdeaUpdateRequest(BaseModel):
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
class IdeaResponse(Idea):
last_generation: Optional[GenerationResponse] = None

View File

@@ -7,14 +7,14 @@ class IdeaService:
def __init__(self, dao: DAO): def __init__(self, dao: DAO):
self.dao = dao self.dao = dao
async def create_idea(self, name: str, description: Optional[str], project_id: str, user_id: str) -> Idea: 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) idea = Idea(name=name, description=description, project_id=project_id, created_by=user_id)
idea_id = await self.dao.ideas.create_idea(idea) idea_id = await self.dao.ideas.create_idea(idea)
idea.id = idea_id idea.id = idea_id
return idea return idea
async def get_ideas(self, project_id: str, limit: int = 20, offset: int = 0) -> List[Idea]: async def get_ideas(self, project_id: Optional[str], user_id: str, limit: int = 20, offset: int = 0) -> List[dict]:
return await self.dao.ideas.get_ideas(project_id, limit, offset) return await self.dao.ideas.get_ideas(project_id, user_id, limit, offset)
async def get_idea(self, idea_id: str) -> Optional[Idea]: async def get_idea(self, idea_id: str) -> Optional[Idea]:
return await self.dao.ideas.get_idea(idea_id) return await self.dao.ideas.get_idea(idea_id)

View File

@@ -6,7 +6,7 @@ class Idea(BaseModel):
id: Optional[str] = None id: Optional[str] = None
name: str = "New Idea" name: str = "New Idea"
description: Optional[str] = None description: Optional[str] = None
project_id: str project_id: Optional[str] = None
created_by: str # User ID created_by: str # User ID
is_deleted: bool = False is_deleted: bool = False
created_at: datetime = Field(default_factory=datetime.now) created_at: datetime = Field(default_factory=datetime.now)

View File

@@ -1,6 +1,7 @@
from typing import List, Optional from typing import List, Optional
import logging import logging
from bson import ObjectId from bson import ObjectId
from uuid import uuid4
from motor.motor_asyncio import AsyncIOMotorClient from motor.motor_asyncio import AsyncIOMotorClient
from models.Asset import Asset from models.Asset import Asset
@@ -19,7 +20,8 @@ class AssetsRepo:
# Main data # Main data
if asset.data: if asset.data:
ts = int(asset.created_at.timestamp()) ts = int(asset.created_at.timestamp())
object_name = f"{asset.type.value}/{ts}_{asset.name}" uid = uuid4().hex[:8]
object_name = f"{asset.type.value}/{ts}_{uid}_{asset.name}"
uploaded = await self.s3.upload_file(object_name, asset.data) uploaded = await self.s3.upload_file(object_name, asset.data)
if uploaded: if uploaded:
@@ -32,7 +34,8 @@ class AssetsRepo:
# Thumbnail # Thumbnail
if asset.thumbnail: if asset.thumbnail:
ts = int(asset.created_at.timestamp()) ts = int(asset.created_at.timestamp())
thumb_name = f"{asset.type.value}/thumbs/{ts}_{asset.name}_thumb.jpg" uid = uuid4().hex[:8]
thumb_name = f"{asset.type.value}/thumbs/{ts}_{uid}_{asset.name}_thumb.jpg"
uploaded_thumb = await self.s3.upload_file(thumb_name, asset.thumbnail) uploaded_thumb = await self.s3.upload_file(thumb_name, asset.thumbnail)
if uploaded_thumb: if uploaded_thumb:
@@ -134,7 +137,8 @@ class AssetsRepo:
if self.s3: if self.s3:
if asset.data: if asset.data:
ts = int(asset.created_at.timestamp()) ts = int(asset.created_at.timestamp())
object_name = f"{asset.type.value}/{ts}_{asset.name}" uid = uuid4().hex[:8]
object_name = f"{asset.type.value}/{ts}_{uid}_{asset.name}"
if await self.s3.upload_file(object_name, asset.data): if await self.s3.upload_file(object_name, asset.data):
asset.minio_object_name = object_name asset.minio_object_name = object_name
asset.minio_bucket = self.s3.bucket_name asset.minio_bucket = self.s3.bucket_name
@@ -142,7 +146,8 @@ class AssetsRepo:
if asset.thumbnail: if asset.thumbnail:
ts = int(asset.created_at.timestamp()) ts = int(asset.created_at.timestamp())
thumb_name = f"{asset.type.value}/thumbs/{ts}_{asset.name}_thumb.jpg" uid = uuid4().hex[:8]
thumb_name = f"{asset.type.value}/thumbs/{ts}_{uid}_{asset.name}_thumb.jpg"
if await self.s3.upload_file(thumb_name, asset.thumbnail): if await self.s3.upload_file(thumb_name, asset.thumbnail):
asset.minio_thumbnail_object_name = thumb_name asset.minio_thumbnail_object_name = thumb_name
asset.thumbnail = None asset.thumbnail = None
@@ -216,7 +221,8 @@ class AssetsRepo:
created_at = doc.get("created_at") created_at = doc.get("created_at")
ts = int(created_at.timestamp()) if created_at else 0 ts = int(created_at.timestamp()) if created_at else 0
object_name = f"{type_}/{ts}_{asset_id}_{name}" uid = uuid4().hex[:8]
object_name = f"{type_}/{ts}_{uid}_{asset_id}_{name}"
if await self.s3.upload_file(object_name, data): if await self.s3.upload_file(object_name, data):
await self.collection.update_one( await self.collection.update_one(
{"_id": asset_id}, {"_id": asset_id},
@@ -243,7 +249,8 @@ class AssetsRepo:
created_at = doc.get("created_at") created_at = doc.get("created_at")
ts = int(created_at.timestamp()) if created_at else 0 ts = int(created_at.timestamp()) if created_at else 0
thumb_name = f"{type_}/thumbs/{ts}_{asset_id}_{name}_thumb.jpg" uid = uuid4().hex[:8]
thumb_name = f"{type_}/thumbs/{ts}_{uid}_{asset_id}_{name}_thumb.jpg"
if await self.s3.upload_file(thumb_name, thumb): if await self.s3.upload_file(thumb_name, thumb):
await self.collection.update_one( await self.collection.update_one(
{"_id": asset_id}, {"_id": asset_id},

View File

@@ -98,7 +98,7 @@ class GenerationRepo:
generations.append(Generation(**generation)) generations.append(Generation(**generation))
return generations return generations
async def cancel_stale_generations(self, timeout_minutes: int = 60) -> int: async def cancel_stale_generations(self, timeout_minutes: int = 5) -> int:
cutoff_time = datetime.now(UTC) - timedelta(minutes=timeout_minutes) cutoff_time = datetime.now(UTC) - timedelta(minutes=timeout_minutes)
res = await self.collection.update_many( res = await self.collection.update_many(
{ {

View File

@@ -20,14 +20,43 @@ class IdeaRepo:
return Idea(**res) return Idea(**res)
return None return None
async def get_ideas(self, project_id: str, limit: int = 20, offset: int = 0) -> List[Idea]: async def get_ideas(self, project_id: Optional[str], user_id: str, limit: int = 20, offset: int = 0) -> List[dict]:
filter = {"project_id": project_id, "is_deleted": False} if project_id:
res = await self.collection.find(filter).sort("updated_at", -1).skip(offset).limit(limit).to_list(None) match_stage = {"project_id": project_id, "is_deleted": False}
ideas = [] else:
for doc in res: match_stage = {"created_by": user_id, "project_id": None, "is_deleted": False}
doc["id"] = str(doc.pop("_id"))
ideas.append(Idea(**doc)) pipeline = [
return ideas {"$match": match_stage},
{"$sort": {"updated_at": -1}},
{"$skip": offset},
{"$limit": limit},
# Add string id field for lookup
{"$addFields": {"str_id": {"$toString": "$_id"}}},
# Lookup generations
{
"$lookup": {
"from": "generations",
"let": {"idea_id": "$str_id"},
"pipeline": [
{"$match": {"$expr": {"$eq": ["$idea_id", "$$idea_id"]}}},
{"$sort": {"created_at": -1}}, # Ensure we get the latest
{"$limit": 1}
],
"as": "generations"
}
},
# Unwind generations array (preserve ideas without generations)
{"$unwind": {"path": "$generations", "preserveNullAndEmptyArrays": True}},
# Rename for clarity
{"$addFields": {
"last_generation": "$generations",
"id": "$str_id"
}},
{"$project": {"generations": 0, "str_id": 0, "_id": 0}}
]
return await self.collection.aggregate(pipeline).to_list(None)
async def delete_idea(self, idea_id: str) -> bool: async def delete_idea(self, idea_id: str) -> bool:
if not ObjectId.is_valid(idea_id): if not ObjectId.is_valid(idea_id):