From 97483b7030b6bd81d06c0e316f7008e582324160 Mon Sep 17 00:00:00 2001 From: xds Date: Sun, 15 Feb 2026 10:26:01 +0300 Subject: [PATCH] + ideas --- aiws.py | 2 + api/dependency.py | 5 ++ api/endpoints/idea_router.py | 61 +++++++++++++ api/models/GenerationRequest.py | 2 + .../GenerationRequest.cpython-313.pyc | Bin 3698 -> 3790 bytes api/service/generation_service.py | 4 + api/service/idea_service.py | 22 +++++ models/Generation.py | 1 + models/Idea.py | 12 +++ models/__pycache__/Generation.cpython-313.pyc | Bin 3329 -> 3377 bytes repos/__pycache__/dao.cpython-313.pyc | Bin 1466 -> 1576 bytes .../generation_repo.cpython-313.pyc | Bin 7177 -> 7404 bytes repos/dao.py | 2 + repos/generation_repo.py | 19 ++++- repos/idea_repo.py | 39 +++++++++ tests/test_idea.py | 80 ++++++++++++++++++ 16 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 api/endpoints/idea_router.py create mode 100644 api/service/idea_service.py create mode 100644 models/Idea.py create mode 100644 repos/idea_repo.py create mode 100644 tests/test_idea.py diff --git a/aiws.py b/aiws.py index 04b17d9..5214dba 100644 --- a/aiws.py +++ b/aiws.py @@ -43,6 +43,7 @@ from api.endpoints.auth import router as api_auth_router from api.endpoints.admin import router as api_admin_router from api.endpoints.album_router import router as api_album_router from api.endpoints.project_router import router as project_api_router +from api.endpoints.idea_router import router as idea_api_router load_dotenv() logger = logging.getLogger(__name__) @@ -210,6 +211,7 @@ app.include_router(api_char_router) app.include_router(api_gen_router) app.include_router(api_album_router) app.include_router(project_api_router) +app.include_router(idea_api_router) # Prometheus Metrics (Instrument after all routers are added) Instrumentator( diff --git a/api/dependency.py b/api/dependency.py index 7dc90eb..51674c5 100644 --- a/api/dependency.py +++ b/api/dependency.py @@ -45,6 +45,11 @@ def get_generation_service( ) -> GenerationService: return GenerationService(dao, gemini, s3_adapter, bot) +from api.service.idea_service import IdeaService + +def get_idea_service(dao: DAO = Depends(get_dao)) -> IdeaService: + return IdeaService(dao) + from fastapi import Header async def get_project_id(x_project_id: Optional[str] = Header(None, alias="X-Project-ID")) -> Optional[str]: diff --git a/api/endpoints/idea_router.py b/api/endpoints/idea_router.py new file mode 100644 index 0000000..b2022b8 --- /dev/null +++ b/api/endpoints/idea_router.py @@ -0,0 +1,61 @@ +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from api.dependency import get_idea_service, get_current_user, get_project_id, get_generation_service +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 + +router = APIRouter(prefix="/ideas", tags=["ideas"]) + +@router.post("", response_model=Idea) +async def create_idea( + name: str, + project_id: str = Depends(get_project_id), + 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.create_idea(name, project_id, str(current_user["_id"])) + +@router.get("", response_model=List[Idea]) +async def get_ideas( + project_id: str = Depends(get_project_id), + limit: int = 20, + offset: int = 0, + 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) + +@router.get("/{idea_id}", response_model=Idea) +async def get_idea( + idea_id: str, + idea_service: IdeaService = Depends(get_idea_service) +): + idea = await idea_service.get_idea(idea_id) + if not idea: + raise HTTPException(status_code=404, detail="Idea not found") + return idea + +@router.delete("/{idea_id}") +async def delete_idea( + idea_id: str, + idea_service: IdeaService = Depends(get_idea_service) +): + success = await idea_service.delete_idea(idea_id) + if not success: + raise HTTPException(status_code=404, detail="Idea not found or could not be deleted") + return {"status": "success"} + +@router.get("/{idea_id}/generations", response_model=List[GenerationResponse]) +async def get_idea_generations( + idea_id: str, + limit: int = 50, + offset: int = 0, + generation_service: GenerationService = Depends(get_generation_service) +): + return await generation_service.get_generations(idea_id=idea_id, limit=limit, offset=offset) diff --git a/api/models/GenerationRequest.py b/api/models/GenerationRequest.py index 33a4010..e6b2ae6 100644 --- a/api/models/GenerationRequest.py +++ b/api/models/GenerationRequest.py @@ -17,6 +17,7 @@ class GenerationRequest(BaseModel): use_profile_image: bool = True assets_list: List[str] project_id: Optional[str] = None + idea_id: Optional[str] = None count: int = Field(default=1, ge=1, le=10) @@ -47,6 +48,7 @@ class GenerationResponse(BaseModel): cost: Optional[float] = None created_by: Optional[str] = None generation_group_id: Optional[str] = None + idea_id: Optional[str] = None created_at: datetime = datetime.now(UTC) updated_at: datetime = datetime.now(UTC) diff --git a/api/models/__pycache__/GenerationRequest.cpython-313.pyc b/api/models/__pycache__/GenerationRequest.cpython-313.pyc index 0726ddb09aee4939f0106e13d72247b80b8f7d21..c74e8026b60fb9d7c8181a37939b3cd5644ee0f5 100644 GIT binary patch delta 1052 zcma))&rcIU6vubFv$Slx)HQ8wOAAXO!tygf#G-(rq6PxC?uEk=)3g<<2skbA2nPdr z!;FU>Of;UncrYG~G4YO}Y0}{S2PPa0_TWjK_X_bxdT=-S%**_EZ@%9*hmlWF^_!|H z0{<2YPjbmO>QhlXU@ygYBc_A;EZJ(1`I_Z4J)JQ6V}hyjzsdBDN;F`3`N|1cF*SN> z(P5lq>yE7k+nLK`Q%sV&#USgKp1DJO2Xz5j*`Cxa>g+&Di<>M#?zHbm+cvIk&+nuk zFO*5!XDv{C@FMwYsHF98Ol=QWNI&gmyW}p<6qkpbI!`iZxY(rIpMaau(*Og|1JD7z zfIh&$F@ikE`#83Qea*iY=q_nJWzw^+`6|R$J21fBDN6j=RI?Uo&l6Bu0b|@=RZ%?4$F02L;<~B~7!b zLN;?NRj`${)yz?hN{tvdBJ4&Fpouijez=0`qY$Dya9?^JFvelax0hB@1v(BB696>s z0z?-9lYmQr%YZ8Y?486TTUuUuND0hM0j>h-neGJ*C?v{*e2hJBJXHVcGTTu)8?Z}* z-1J-^4hOK>5JWo1oY4r{5~vNeVAgu|rr`_#(i}HA6e*Jkwz{jN85LsGnmi3nW*^+s zI+_44!d91Z%lE+Gd_G&q|5?2ctKWe9c!he^XJHjE&Q?7wHC3}%<+bOmhGs&QqqI&C zHHvBLB}>9i0G!J^Irw|^csFNiw@Z4(N)aV8(#|z*=o$vqs_-)tN{guqxQg(vQ rv!7~LhawIX8><2zs$qxdFB(;W4^<^7_7y`_fe+OYHq+EjpfkS!E+^0b delta 946 zcmZvaO>7cD6vua9`Q8OLVWE`I0<};SE3LJ)*fh}|Y8yi3;K5A@0#X!<&MwiT2V;!~ zFXMrOiN=`d)p+#o$r!6igL}}khbE@(!HbFWX4@JgyV>7N_Pu%k_c0%$dlB)cD7rcN zeEsyOExr~XBQzk-(c7frPjVH3zGB5YZ^g3WQydlF1of;O!GV?~D-mMZA}Q+x3X)ap zlcbZHaEwBit5sF2Y3+KorfIl+rKdI3Y8A(cm#?Ec1oIp67b(!c^=$oBVOz@IU4AgV z!{>31{N}IGU>mkVCr;y(vQC_Upksg}AO)}i#sL|?Bmn$z4sZ;RKR}RfR6oTQH<09y zfyr$t_nFTPB>xWYHxJ~=4DT3psmo|y_=2wJxe${#_A*M;%EtGfKLPL z014m$2!J^Nt9u@HX8;Qny6tMGThnk6ew+ocmMoHou4vH;4%W@?ZOnt^JYX3xtYeTf zOFTk1s-Nt-_Q~mePKM>mVh$5qfir+v z^34-9HC9Xw;lel+hGAy9*lBNGhu*1bQ&ay}DS;=G8m6)aR=@&zFT_lhHL6k$EP(|S z_Cq=j6Q3Cts0^NfCHkoj3VNFa-~Cnd0+q=N@!mAMa;#!j9TR5<|Gk>Jvv{N1=yjTl r_!7D7&7@uEMBim_bTLLONbYA1jxL5fh>rI|21gfThP?Eqc$mZ=$*jQ4 diff --git a/api/service/generation_service.py b/api/service/generation_service.py index d729d83..b241923 100644 --- a/api/service/generation_service.py +++ b/api/service/generation_service.py @@ -137,6 +137,10 @@ class GenerationService: if generation_group_id: generation_model.generation_group_id = generation_group_id + # Explicitly set idea_id from request if present (already in model_dump, but ensuring clarity) + if generation_request.idea_id: + generation_model.idea_id = generation_request.idea_id + gen_id = await self.dao.generations.create_generation(generation_model) generation_model.id = gen_id diff --git a/api/service/idea_service.py b/api/service/idea_service.py new file mode 100644 index 0000000..fd1641c --- /dev/null +++ b/api/service/idea_service.py @@ -0,0 +1,22 @@ +from typing import List, Optional +from repos.dao import DAO +from models.Idea import Idea + +class IdeaService: + def __init__(self, dao: DAO): + self.dao = dao + + async def create_idea(self, name: str, project_id: str, user_id: str) -> Idea: + idea = Idea(name=name, 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_idea(self, idea_id: str) -> Optional[Idea]: + return await self.dao.ideas.get_idea(idea_id) + + async def delete_idea(self, idea_id: str) -> bool: + return await self.dao.ideas.delete_idea(idea_id) diff --git a/models/Generation.py b/models/Generation.py index 9a75c84..8f164f9 100644 --- a/models/Generation.py +++ b/models/Generation.py @@ -38,6 +38,7 @@ class Generation(BaseModel): generation_group_id: Optional[str] = None created_by: Optional[str] = None # Stores User ID (Telegram ID or Web User ObjectId) project_id: Optional[str] = None + idea_id: Optional[str] = None created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) diff --git a/models/Idea.py b/models/Idea.py new file mode 100644 index 0000000..0a7ebb1 --- /dev/null +++ b/models/Idea.py @@ -0,0 +1,12 @@ +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + +class Idea(BaseModel): + id: Optional[str] = None + name: str = "New Idea" + project_id: str + created_by: str # User ID + is_deleted: bool = False + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) diff --git a/models/__pycache__/Generation.cpython-313.pyc b/models/__pycache__/Generation.cpython-313.pyc index cb9ecdb2d77fb9c455b1c77b3cb8f4ea7aa01bbf..189b987340a052776af22e9a4094f153bf176a95 100644 GIT binary patch delta 295 zcmZpa+9<{QnU|M~0SF>XCT7NO%l$$4CAj2)Y&a=9?_-H=smaPNqiklY!2 knMHB(P44ZC0h1^4s53@S-pOOhugoYsLGmjDkOEr`07!8^#{d8T delta 248 zcmdle)hNaLnU|M~0SLG+^<~;_HqT?9!^o&J*_tyONG|6*FX{{uX9f|j yAOht4BHzgqxYQV{HgD&0VVum#vz^g@@Q z2uI;>tfn;44)nGoB!rB>{`Z9$#|SSok5e)Pm^czai(B?O_p0M#i==IMf@Nw0+UUd)7U`F zV&BONnf+9Hxjuu;(qt!~i6~x&WjWM+5)> diff --git a/repos/__pycache__/generation_repo.cpython-313.pyc b/repos/__pycache__/generation_repo.cpython-313.pyc index a36ef065d34ea1a6dbc954970959ccc52b454cbd..235953dbecef798df2f695734957d0627cf06ff2 100644 GIT binary patch delta 1533 zcmZ`(O-vg{6rLIHuGikRF?a!EW5)y{Y$$>TBOs7cic)A=Ov2#cf)ix0ad3>O#)ec) zCHi-0ZlGz^N|mZqy|g`ntV)p@RZVW~rQW6zmQT$QsRt*hJ@nGftPM>g^-259`{w=3 zoA-A2_R#Oc_9L6k3Ye_tch^GNzI_yW8#rfjGz$Uv7{ma97!ZUZG2vF4f@AavY@+vI z>xd&}iJ3*_ip7{MCUGs~!1ZpB7SfQ|eZU8>4_i;X6s9SLe*?83@AlL2YRHbAwa7ZG zv3$>F7H}iC69*PzQV8KDgVp2anE9#m1?)2R8}KRI5>uH)4GGv?%k0D+qNOZBf7w2n zEPRH35w^?bwL~($w3b+r`K@ch_4nCx^b>1BJMgncV2Dv4%*qkCEC3L7VN)k_h#-Rn zuwXE8S!K2;77Zq?u-jq~k+D?ehVS(nGjryPzKP9-(Hy9q9Y-uko`Kkswa|CPE)Dh%3b&~ny823Xu7k3amIN!hwWlJR^pu@ukKj3o?h^gBy? zui;geR@XB5Bq>{S8zjH9K@v$qI#?uea%g3c-lRH2{_z(U0bQM zdq>)NTX%Gnltx`?E4BCRjOwbtq%`P?ujKFC8P-+rpEB^A-o1Q>d}#embA2+iJ9D4~ zbS>~z(?hNAKpW7tfxXNR7awYDe@@rt=shdhZklL^4_t7fO?c2UZegS>$1}^D>wL~; zR?xASm}1QNbEZ`1+y}De+KihqGa$cn$)V%{Q7DUG)f(wOs!}m}bxS33#>etM(i+YA08y)chq}qTe*H!X&MCed{!b!-xD2Mc79H z!r^dG3eyAEmV1Kly}|)=h;q2fAx%>)L-132r)6^c8d^q=O9(%`x3`r2GE68&+yO0rwUn{VmaTL!Bj5Jp!}1%P?9fF84I&>}jv zq|gm+$)GK6NuitQ50iIW^i*B|Z@@fcvDI2^;>h0AUA#YC0Zc8T&qz+F0t%I@usiSI1p)@<0>pr_!*mrv&yhVTZbk|*n@bnYFk$6~}{sB_k BSaJXW delta 1303 zcmZ8gO>7%Q6rNe{|E~XPZ{pZalafN}q*3!@NN|(3ZoqYE;)YDONg9VJzb0;QtFdW; zR)il3!3Elm5Jx~rAjBo96{#1B=&hXAszfYbIYn@Ai8yeAnce&#p0wY*Z{9cWy?LYk zuCuH@wjt=df@S*_C(skU|5j6}7FdiF{1 z+*WX;5*#5(4L8GK4}9c;!#e+nPb!quG8b>PnYle zra5@A>rxj+fszj&mYQ zixDX|egkL7L}(daArC{Fele5TX8%0CN+FYp3FM=u8{so-C&gflfyrQ+!Rw?UJOCHT z#qhbcGu%tuQ&&)ktRH*Ox6xGx)V0PYt*MpDSl(vUXOGpHpxU2s)=|^CJoR|SA=hqN zZ{4a~yKS>N2hMY&T%9(-O>$2GWmDW=>~Q@!xDHE@3fAB-EvN}6*V7Ns#`)1YpuRPJ z#hSia8DF(owWD{v@B6MgQ&mSUS?1*9QHK<6SU2CP6mHp!CQEY{XnrcIhs*aOEfBs< T?ngVlGY~%WCt!#p^uqrF2! List[Generation]: + limit: int = 10, offset: int = 0, created_by: Optional[str] = None, project_id: Optional[str] = None, idea_id: Optional[str] = None) -> List[Generation]: filter = {"is_deleted": False} if character_id is not None: @@ -35,11 +35,20 @@ class GenerationRepo: filter["status"] = status if created_by is not None: filter["created_by"] = created_by - filter["project_id"] = None + # If filtering by created_by user (e.g. "My Generations"), we typically imply personal scope if project_id is None. + # But if project_id is passed, we filter by that. + if project_id is None: + filter["project_id"] = None if project_id is not None: filter["project_id"] = project_id + if idea_id is not None: + filter["idea_id"] = idea_id - res = await self.collection.find(filter).sort("created_at", -1).skip( + # If fetching for an idea, sort by created_at ascending (cronological) + # Otherwise typically descending (newest first) + sort_order = 1 if idea_id else -1 + + res = await self.collection.find(filter).sort("created_at", sort_order).skip( offset).limit(limit).to_list(None) generations: List[Generation] = [] for generation in res: @@ -48,7 +57,7 @@ class GenerationRepo: return generations async def count_generations(self, character_id: Optional[str] = None, status: Optional[GenerationStatus] = None, - album_id: Optional[str] = None, created_by: Optional[str] = None, project_id: Optional[str] = None) -> int: + album_id: Optional[str] = None, created_by: Optional[str] = None, project_id: Optional[str] = None, idea_id: Optional[str] = None) -> int: args = {} if character_id is not None: args["linked_character_id"] = character_id @@ -58,6 +67,8 @@ class GenerationRepo: args["created_by"] = created_by if project_id is not None: args["project_id"] = project_id + if idea_id is not None: + args["idea_id"] = idea_id return await self.collection.count_documents(args) async def get_generations_by_ids(self, generation_ids: List[str]) -> List[Generation]: diff --git a/repos/idea_repo.py b/repos/idea_repo.py new file mode 100644 index 0000000..6ea79f9 --- /dev/null +++ b/repos/idea_repo.py @@ -0,0 +1,39 @@ +from typing import Optional, List +from bson import ObjectId +from motor.motor_asyncio import AsyncIOMotorClient +from models.Idea import Idea + +class IdeaRepo: + def __init__(self, client: AsyncIOMotorClient, db_name="bot_db"): + self.collection = client[db_name]["ideas"] + + async def create_idea(self, idea: Idea) -> str: + res = await self.collection.insert_one(idea.model_dump()) + return str(res.inserted_id) + + async def get_idea(self, idea_id: str) -> Optional[Idea]: + if not ObjectId.is_valid(idea_id): + return None + res = await self.collection.find_one({"_id": ObjectId(idea_id)}) + if res: + res["id"] = str(res.pop("_id")) + 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 delete_idea(self, idea_id: str) -> bool: + if not ObjectId.is_valid(idea_id): + return False + res = await self.collection.update_one( + {"_id": ObjectId(idea_id)}, + {"$set": {"is_deleted": True}} + ) + return res.modified_count > 0 diff --git a/tests/test_idea.py b/tests/test_idea.py new file mode 100644 index 0000000..18fe1ee --- /dev/null +++ b/tests/test_idea.py @@ -0,0 +1,80 @@ +import asyncio +import os +from dotenv import load_dotenv +from motor.motor_asyncio import AsyncIOMotorClient +from bson import ObjectId + +# Import from project root (requires PYTHONPATH=.) +from api.service.idea_service import IdeaService +from repos.dao import DAO +from models.Idea import Idea +from models.Generation import Generation, GenerationStatus +from models.enums import AspectRatios, Quality + +load_dotenv() + +MONGO_HOST = os.getenv("MONGO_HOST", "mongodb://localhost:27017") +DB_NAME = os.getenv("DB_NAME", "bot_db") + +print(f"Connecting to MongoDB: {MONGO_HOST}, DB: {DB_NAME}") + +async def test_idea_flow(): + client = AsyncIOMotorClient(MONGO_HOST) + dao = DAO(client, db_name=DB_NAME) + service = IdeaService(dao) + + # 1. Create an Idea + print("Creating idea...") + user_id = "test_user_123" + project_id = "test_project_abc" + idea = await service.create_idea("My Test Idea", project_id, user_id) + print(f"Idea created: {idea.id} - {idea.name}") + + # 2. Add Generation linked to Idea + print("Creating generation linked to idea...") + gen = Generation( + prompt="idea generation 1", + idea_id=idea.id, + project_id=project_id, + created_by=user_id, + aspect_ratio=AspectRatios.NINESIXTEEN, + quality=Quality.ONEK, + assets_list=[] + ) + gen_id = await dao.generations.create_generation(gen) + print(f"Created linked generation: {gen_id}") + + # Debug: Check if generation was saved with idea_id + saved_gen = await dao.generations.collection.find_one({"_id": ObjectId(gen_id)}) + print(f"DEBUG: Saved Generation in DB idea_id: {saved_gen.get('idea_id')}") + + # 3. Fetch Generations for Idea (Verify filtering and ordering) + print("Fetching generations for idea...") + gens = await dao.generations.get_generations(idea_id=idea.id) + print(f"Found {len(gens)} generations in idea") + + if len(gens) == 1 and gens[0].id == gen_id: + print("✅ Generation retrieval successful") + else: + print("❌ Generation retrieval FAILED") + + # 4. Fetch Ideas for Project + ideas = await service.get_ideas(project_id) + print(f"Found {len(ideas)} ideas for project") + + # Cleaning up + print("Cleaning up...") + await service.delete_idea(idea.id) + await dao.generations.collection.delete_one({"_id": ObjectId(gen_id)}) + + # Verify deletion + deleted_idea = await service.get_idea(idea.id) + # IdeaRepo.delete_idea logic sets is_deleted=True + if deleted_idea and deleted_idea.is_deleted: + print(f"✅ Idea deleted successfully") + + # Hard delete for cleanup + await dao.ideas.collection.delete_one({"_id": ObjectId(idea.id)}) + +if __name__ == "__main__": + asyncio.run(test_idea_flow())