From 68a3f529cb066923f5718831c5997d0160bf9302 Mon Sep 17 00:00:00 2001 From: xds Date: Mon, 16 Feb 2026 16:35:26 +0300 Subject: [PATCH] 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. --- aiws.py | 2 +- api/__pycache__/dependency.cpython-313.pyc | Bin 2727 -> 2727 bytes api/endpoints/idea_router.py | 16 +++---- api/models/IdeaRequest.py | 5 ++ .../GenerationRequest.cpython-313.pyc | Bin 3790 -> 3790 bytes .../generation_service.cpython-313.pyc | Bin 28063 -> 28063 bytes api/service/idea_service.py | 6 +-- models/Idea.py | 2 +- models/__pycache__/Generation.cpython-313.pyc | Bin 3377 -> 3377 bytes repos/__pycache__/assets_repo.cpython-313.pyc | Bin 14845 -> 15403 bytes repos/__pycache__/dao.cpython-313.pyc | Bin 1576 -> 1576 bytes .../generation_repo.cpython-313.pyc | Bin 7404 -> 7404 bytes repos/assets_repo.py | 19 +++++--- repos/generation_repo.py | 2 +- repos/idea_repo.py | 45 ++++++++++++++---- 15 files changed, 67 insertions(+), 30 deletions(-) diff --git a/aiws.py b/aiws.py index 5214dba..e08bdda 100644 --- a/aiws.py +++ b/aiws.py @@ -130,7 +130,7 @@ async def start_scheduler(service: GenerationService): break except Exception as 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) --- @asynccontextmanager diff --git a/api/__pycache__/dependency.cpython-313.pyc b/api/__pycache__/dependency.cpython-313.pyc index 6e621584864b40f6e4e55425c6c032fd98273407..21db54a428147dd2606d1e4399ebe3c36848959a 100644 GIT binary patch delta 20 acmZ23x?Gg|GcPX}0}y0dPuj>mhYJ8V*ab5H delta 20 acmZ23x?Gg|GcPX}0}v#ZOx(ylhYJ8W@daZ5 diff --git a/api/endpoints/idea_router.py b/api/endpoints/idea_router.py index f6ceb89..a9b612d 100644 --- a/api/endpoints/idea_router.py +++ b/api/endpoints/idea_router.py @@ -6,34 +6,30 @@ from api.service.idea_service import IdeaService from api.service.generation_service import GenerationService from models.Idea import Idea 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.post("", response_model=Idea) async def create_idea( request: IdeaCreateRequest, - project_id: str = Depends(get_project_id), + project_id: Optional[str] = Depends(get_project_id), current_user: dict = Depends(get_current_user), 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 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( - project_id: str = Depends(get_project_id), + project_id: Optional[str] = Depends(get_project_id), limit: int = 20, offset: int = 0, + current_user: dict = Depends(get_current_user), idea_service: IdeaService = Depends(get_idea_service) ): - if not project_id: - raise HTTPException(status_code=400, detail="Project ID header is required") - return await idea_service.get_ideas(project_id, limit, offset) + return await idea_service.get_ideas(project_id, str(current_user["_id"]), limit, offset) @router.get("/{idea_id}", response_model=Idea) async def get_idea( diff --git a/api/models/IdeaRequest.py b/api/models/IdeaRequest.py index 773f38c..82138f7 100644 --- a/api/models/IdeaRequest.py +++ b/api/models/IdeaRequest.py @@ -1,5 +1,7 @@ from typing import Optional from pydantic import BaseModel +from models.Idea import Idea +from api.models.GenerationRequest import GenerationResponse class IdeaCreateRequest(BaseModel): name: str @@ -9,3 +11,6 @@ class IdeaCreateRequest(BaseModel): class IdeaUpdateRequest(BaseModel): name: Optional[str] = None description: Optional[str] = None + +class IdeaResponse(Idea): + last_generation: Optional[GenerationResponse] = None diff --git a/api/models/__pycache__/GenerationRequest.cpython-313.pyc b/api/models/__pycache__/GenerationRequest.cpython-313.pyc index c74e8026b60fb9d7c8181a37939b3cd5644ee0f5..66faad5b2906050223630322dc53f65705b1b5ea 100644 GIT binary patch delta 20 acmX>ndrp@7GcPX}0}y0dPuj?Rm=6Fy6a~Bh delta 20 acmX>ndrp@7GcPX}0}#ZPOx(zQm=6Fz5e4V~ diff --git a/api/service/__pycache__/generation_service.cpython-313.pyc b/api/service/__pycache__/generation_service.cpython-313.pyc index 1d495681a66f71ea4f7d1f469ce9454fa7e1e73e..431481c87b5173083e1f1b12b4f28e8ada167fb5 100644 GIT binary patch delta 22 ccmbP#n{obaM()qNyj%=GkZC<>BlnbC09R-S3;+NC delta 22 ccmbP#n{obaM()qNyj%=G(AhX~BlnbC09oe;aR2}S diff --git a/api/service/idea_service.py b/api/service/idea_service.py index 89c430f..e154564 100644 --- a/api/service/idea_service.py +++ b/api/service/idea_service.py @@ -7,14 +7,14 @@ class IdeaService: def __init__(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_id = await self.dao.ideas.create_idea(idea) idea.id = idea_id return idea - async def get_ideas(self, project_id: str, limit: int = 20, offset: int = 0) -> List[Idea]: - return await self.dao.ideas.get_ideas(project_id, limit, offset) + 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, user_id, limit, offset) async def get_idea(self, idea_id: str) -> Optional[Idea]: return await self.dao.ideas.get_idea(idea_id) diff --git a/models/Idea.py b/models/Idea.py index 305ecc2..4a2aeab 100644 --- a/models/Idea.py +++ b/models/Idea.py @@ -6,7 +6,7 @@ class Idea(BaseModel): id: Optional[str] = None name: str = "New Idea" description: Optional[str] = None - project_id: str + project_id: Optional[str] = None created_by: str # User ID is_deleted: bool = False created_at: datetime = Field(default_factory=datetime.now) diff --git a/models/__pycache__/Generation.cpython-313.pyc b/models/__pycache__/Generation.cpython-313.pyc index 189b987340a052776af22e9a4094f153bf176a95..0e828bbdfd4361520f99942786c37645c59b38ee 100644 GIT binary patch delta 20 acmdlewNZ-uGcPX}0}y0dPuj?>#tQ&B9|Yb2 delta 20 acmdlewNZ-uGcPX}0}w=(Ox(z=#tQ&C00jmB diff --git a/repos/__pycache__/assets_repo.cpython-313.pyc b/repos/__pycache__/assets_repo.cpython-313.pyc index 60c530c6293ff21aea8fb8b3a33b9fba6eef626e..b1af289b1fd76521653ff03c1c012f7882ee0bfa 100644 GIT binary patch delta 4543 zcma)AYfN0n6`tAq*cZ#fzFA(oycYuO18m3j3kWZRu>)DvF*sy_g<#`de0Ql6J1uUb z`cKjpI*OeBsc`<7$ZAD|s&1=F#&HdBnO|Y^3IWAjF3QHC1 zCL=yVjy8-L*;{fUbGdBgbPT7+pB@sXW9x1ba=5s6GZfv0Y^BVm3G;z$Fs2j|HUi>- zUfmvcBr}(O@O7szKRA6ISk;DfF?quQAp%xcv{rT?$7zdc!`f{wM8>io>O$6rL-)CG z$zm0B5A%U^m$27eR$9)o%dGHg9E4K$oP0PHsmv_E3_Xg4j?oj zG$L%yEiHqIWK>Cu5oIKy?7_+`fV40aALNUx6#Y1>kUE$rtBlsow`MuT$$LJwV*56P zb^u?7>XQzA>W{~wiiB0@g3^Vs-38?^>bSAZ$@_Hbq`!$(IMuXaT#28E4kkzAF?D)w zOlP&*6Cfqf=$7sFg{_1>Ghbk@q)x2i991R~QcOJLWfInO6UcEld(LrOdx2nC<~e_r zec=GgGD8>s)UzYbTpduB1}IBpe|0(7-(?$ehRRlASOi&w{tosd(Jp9X5}S59ojDr@ z?tPDT!x$<9c+3^RTiy4#3j5GJ$W@zW%*<+CIh0cn``pp~wI(BR>fAb+xM-JTPCyTv z&cG%imE;eMVQTURXj3zWYtJ8WtQ4ptSJH+_cNwP?9|Pve*KVN@x)BT8IDiHhIV_Dd>Bh9Kqy4;Ab1gQWBp4k zfi6A=-(YEd^Rt7|alQ?B67YRfjBPQ!lu`ubDWwd6ecx>#!!%JIL8wN++)%S5gc8p_ z!^HSdBpKb@tJ}68Qk${^VJ8BTgt8j}``^Fq*4&lyB{ytzHQ-*M)#GZ)WIpPLEJ zG)zrSS>G-yzg&5#@=j53r6{=U4b27Sy5}BC(NuBDo{B6;ONMKP)bq=Qo$O-n@x2dr zzn>&4Qo&RSbQDq(3k?g=r4!dqEc7iu-n(4T$8z!pyYh*>75UBHLDvjK-?zbXO$BY) z^ty+(8N}<}rYtBd256f}Tnsi?pm0;9ZD#SNB%^Gih-N@`EAR4tp6pLy00sek0uQ0` zG%7I9LBUT1p1Rl~Apk&TDh~LjZEPjq#(tacr9YkjApaIsSww>!HR=+!piGqx`0(}L zLM(c9tb z?4g(D&w0L04~=4=IP=0VuC$$qdhaVi>1CVbMCVK!SUk3*LP285di!_7s0 zdS(7wMe}qLLwHsw8I7s@h2C%RQmgVM4yJHd|Br8{u@QzkZHNybKf=kfg}>3m`y2!X zn=J{@U(BaVzSNZCR9g5^q9z4j37s0}C!tQAipG$v6ST%KcQ= zIAbU}7G*Pfy9ouzE8hC*{P~IyH5#Gm3-ErDls9gS^Y8f%QcLgV&8;4)@nI3Gt)fTl zF$4D+!+5323L5~k!$yu0M=jhG^|1`NXjRcg_U|eed#OsVX(jBRJ{PqjSs8U<38=|( zbOBf&Zbj@LvQ-4TR`z+7cfYL~299Z3=^^-rg&wsmK-o@+j~$8)@seKL#Qf2c z$Ty&$iAAJBYG>ywo$O?7#%@R01oII$7!j_Mn-&Ytjoi6nCuk_$uKp* ze0MpWHJXjCvCJNA%nWZ{O76@6T7`BA7 z*|C~pP5YLJ>DMC`#x_ojmk!M4)(F7u$7To6#|#FUe1DYE+G#MRo+IZ z4nG*GO&eQ6)%z8pf+H#{gv~H!evMc{_~BB(7HivREi?Q5Px3>7^FB{4+~aDd!OsP_ zR87QA#^PsUN;#IK(NmF;s7gKeokK`+Kxf=gHMhZ-zq0J!3Bn~0*5GLcO#>+NrhfJ} z|A~%F^`>?&mCs; zYM0B2yL8ph)g9*)L}lpAjFPb{?D ztX$3yvnzF{DP#Hd9ca1?OvmaACwI|nL~JS1ToY-tUA`s>DC=k^=naj*<~+^yJ+#>+ zU*FpxK;Z_V&2ISy)uU_>pw$hNgtCRpb_%_3xF~Ae9P(P2hH*=Q-cJB+ZNhkmv37QDCgsF zw>?~6%H;|}XSH-YSld}4z3Ih)-mH*7^JcXXWF;F`%%|x}%L8#B-vOW8^&$QRplH~O zJ2L1Ldu2!SfEE?V#|r)eQ=ABR8mcGcUX*bErNx1Gd`uCr97cdVBT57zif|Gkj*vun z9so|x(ZQth1Gax>ZVq1b6};tg%2$xpIb^WJ&QhyaDw&m534G>?KgG{l3%jy&+L15y z(b>={!SCF}r~JFRk9F_*nX8dXhFS45f?w4z+VokN^U?B25&wu#e}7s}#glQRh9ev- z&&38u;|e;R&Nvl^^CnTlubABDv^6pm8He9BiJC;He_iHrN$cTvQ)hexGGCd-x$|7( zUTpU)t=n@d-ZeBa7Tu@JKtl>|0yAt?pp^cFc>hc??~m$yZ)drIWK=EHi^{mF+t>>{SU>f*V6z1 delta 3978 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 delta 20 acmZ3%vx0~FGcPX}0}#ZQOx(yV%LV{8!vxL% diff --git a/repos/__pycache__/generation_repo.cpython-313.pyc b/repos/__pycache__/generation_repo.cpython-313.pyc index 235953dbecef798df2f695734957d0627cf06ff2..767e94e867a88a0123484f22935487bee0c59d39 100644 GIT binary patch delta 36 qcmaE3`Nop>GcPX}0}x2qP0BRh$lEB%$hx^-GJuIudGiBl9!3DfF$vuO delta 36 qcmaE3`Nop>GcPX}0}$kvOw2Ud$lEB%XtTLrGJuKEc=H2k9!3Dup$e}6 diff --git a/repos/assets_repo.py b/repos/assets_repo.py index deaeac1..651b8cb 100644 --- a/repos/assets_repo.py +++ b/repos/assets_repo.py @@ -1,6 +1,7 @@ from typing import List, Optional import logging from bson import ObjectId +from uuid import uuid4 from motor.motor_asyncio import AsyncIOMotorClient from models.Asset import Asset @@ -19,7 +20,8 @@ class AssetsRepo: # Main data if asset.data: 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) if uploaded: @@ -32,7 +34,8 @@ class AssetsRepo: # Thumbnail if asset.thumbnail: 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) if uploaded_thumb: @@ -134,7 +137,8 @@ class AssetsRepo: if self.s3: if asset.data: 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): asset.minio_object_name = object_name asset.minio_bucket = self.s3.bucket_name @@ -142,7 +146,8 @@ class AssetsRepo: if asset.thumbnail: 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): asset.minio_thumbnail_object_name = thumb_name asset.thumbnail = None @@ -216,7 +221,8 @@ class AssetsRepo: created_at = doc.get("created_at") 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): await self.collection.update_one( {"_id": asset_id}, @@ -243,7 +249,8 @@ class AssetsRepo: created_at = doc.get("created_at") 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): await self.collection.update_one( {"_id": asset_id}, diff --git a/repos/generation_repo.py b/repos/generation_repo.py index ab1ab3d..f77ceee 100644 --- a/repos/generation_repo.py +++ b/repos/generation_repo.py @@ -98,7 +98,7 @@ class GenerationRepo: generations.append(Generation(**generation)) 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) res = await self.collection.update_many( { diff --git a/repos/idea_repo.py b/repos/idea_repo.py index bb65e0e..4271018 100644 --- a/repos/idea_repo.py +++ b/repos/idea_repo.py @@ -20,14 +20,43 @@ class IdeaRepo: return Idea(**res) return None - async def get_ideas(self, project_id: str, limit: int = 20, offset: int = 0) -> List[Idea]: - filter = {"project_id": project_id, "is_deleted": False} - res = await self.collection.find(filter).sort("updated_at", -1).skip(offset).limit(limit).to_list(None) - ideas = [] - for doc in res: - doc["id"] = str(doc.pop("_id")) - ideas.append(Idea(**doc)) - return ideas + async def get_ideas(self, project_id: Optional[str], user_id: str, limit: int = 20, offset: int = 0) -> List[dict]: + if project_id: + match_stage = {"project_id": project_id, "is_deleted": False} + else: + match_stage = {"created_by": user_id, "project_id": None, "is_deleted": False} + + pipeline = [ + {"$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: if not ObjectId.is_valid(idea_id):