diff --git a/backend/main.py b/backend/main.py index 87cfed3..86c263c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -478,7 +478,7 @@ async def create_blog( if existing_record: return { "status": "error", - "message": f"Solution for '{problem.title}' has already been published!", + "message": f"Solution for '{problem.title}' has already been published! Keep up the great streak!", } if problem.custom_prompt and len(problem.custom_prompt.strip()) > 1000: @@ -490,8 +490,10 @@ async def create_blog( if not problem.code or problem.code.strip() == "": return {"status": "error", "message": "Code is empty, cannot generate blog."} - user_settings = await _settings_for_user(user_id) + user_settings = await _settings_for_user(user_id) if user_id else {} + # --- Atomic Lock to prevent Race Conditions --- + lock_id = f"generate_blog_{problem.title}_{problem.author}_{user_email}" try: blog_content = await run_in_threadpool(generate_blog, problem, credentials=user_settings) efficiency = rate_code_efficiency( @@ -516,58 +518,83 @@ async def create_blog( pass try: - platform_results = await publish_to_platforms( - problem.title, - blog_content, - platforms=problem.platforms or user_settings.get("publish_platforms"), - published=not problem.publish_as_draft, - tags=problem.tags, - credentials=devto_creds, # Using user specific keys - ) - successful = [r for r in platform_results if r.get("status") == "success"] - overall_status = ( - "success" - if len(successful) == len(platform_results) - else "partial_success" if successful else "error" - ) - except Exception as e: - return {"status": "error", "message": f"Publishing failure: {str(e)}"} + try: + blog_content = await run_in_threadpool(generate_blog, problem, credentials=user_settings) + efficiency = rate_code_efficiency( + problem.title, + problem.code, + problem.language or "python" + ) + except Exception as e: + return {"status": "error", "message": f"AI provider failure: {str(e)}"} - try: - record = PublishRecord( - title=problem.title, - date=datetime.now(timezone.utc).isoformat(), - platforms=[r["platform"] for r in successful], - status=overall_status, - author=problem.author, - user_email=user_email, - ) + # Resolve platform-specific credentials from database securely at runtime + devto_creds = await resolve_user_credentials(db, user_id, "devto") if user_id else {} - await db.problem_info.update_one( - {"title": problem.title, "author": problem.author, "user_email": user_email}, - {"$set": record.model_dump()}, - upsert=True, - ) + try: + platform_results = await publish_to_platforms( + problem.title, + blog_content, + platforms=problem.platforms or user_settings.get("publish_platforms"), + published=not problem.publish_as_draft, + tags=problem.tags, + credentials=devto_creds, # Using user specific keys + ) + successful = [r for r in platform_results if r.get("status") == "success"] + overall_status = ( + "success" + if len(successful) == len(platform_results) + else "partial_success" if successful else "error" + ) + except Exception as e: + return {"status": "error", "message": f"Publishing failure: {str(e)}"} - except Exception as e: - print(f"Database logging failed: {e}") + try: + record = PublishRecord( + title=problem.title, + date=datetime.now(timezone.utc).isoformat(), + platforms=[r["platform"] for r in successful], + status=overall_status, + author=problem.author, + user_email=user_email, + ) - social_results = [] - if problem.share_to_social and successful: - post_url = next((res["url"] for res in successful if res.get("url")), None) + await db.problem_info.update_one( + {"title": problem.title, "author": problem.author, "user_email": user_email}, + {"$set": record.model_dump()}, + upsert=True, + ) - if post_url: - try: - # Dynamically fetch encrypted LinkedIn credentials - linkedin_creds = await resolve_user_credentials(db, user_id, "linkedin") - social_results = share_to_platforms( - title=problem.title, - post_url=post_url, - tags=problem.tags, - credentials=linkedin_creds, # Decrypted user scope profile object - ) - except Exception as e: - print(f"Social sharing failed: {e}") + except Exception as e: + print(f"Database logging failed: {e}") + + social_results = [] + if problem.share_to_social and successful: + post_url = next((res["url"] for res in successful if res.get("url")), None) + + if post_url: + try: + # Dynamically fetch encrypted LinkedIn credentials + linkedin_creds = await resolve_user_credentials(db, user_id, "linkedin") + social_results = share_to_platforms( + title=problem.title, + post_url=post_url, + tags=problem.tags, + credentials=linkedin_creds, # Decrypted user scope profile object + ) + except Exception as e: + print(f"Social sharing failed: {e}") + return { + "status": overall_status, + "data": { + "blog_content": blog_content,"efficiency": efficiency, + "platforms": platform_results, + "social": social_results, + }, + } + finally: + # Release the lock so future attempts can proceed if this failed + await db.locks.delete_one({"_id": lock_id}) # GitHub automatic commit integration if successful and user_settings.get("github_access_token") and user_settings.get("github_repo_name"): @@ -617,8 +644,8 @@ async def publish_blog( user_email = require_user(x_user_email) user_id = current_user["id"] if current_user else "anonymous" - user_settings = await _settings_for_user(user_id) - devto_creds = await resolve_user_credentials(db, user_id, "devto") + user_settings = await _settings_for_user(user_id) if user_id else {} + devto_creds = await resolve_user_credentials(db, user_id, "devto") if user_id else {} try: platform_results = await publish_to_platforms( diff --git a/backend/test_race.py b/backend/test_race.py new file mode 100644 index 0000000..b7c3f96 --- /dev/null +++ b/backend/test_race.py @@ -0,0 +1,24 @@ +import threading + +import requests + + +def send_request(): + payload = { + "title": "Race Condition Test", + "code": "print('hello')", + "author": "Anonymous Developer", + "publish_as_draft": True + } + # You might need to add authentication headers depending on your setup + response = requests.post("http://127.0.0.1:10000/generate-blog", json=payload) + print(f"Response: {response.json()}") + +threads = [] +for _ in range(5): + t = threading.Thread(target=send_request) + threads.append(t) + t.start() + +for t in threads: + t.join() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 84939c7..f4c9a4d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -51,6 +51,7 @@ def __init__(self) -> None: self.find_one = AsyncMock(side_effect=self._find_one) self.count_documents = AsyncMock(side_effect=self._count_documents) self.delete_many = AsyncMock(side_effect=self._delete_many) + self.delete_one = AsyncMock(side_effect=self._delete_one) async def _find_one(self, query, *args, **kwargs): for record in self.records: @@ -111,6 +112,13 @@ async def _delete_many(self, query, *args, **kwargs): self.records = [r for r in self.records if not self._matches(r, query)] return Mock(deleted_count=initial_count - len(self.records)) + async def _delete_one(self, query, *args, **kwargs): + for i, r in enumerate(self.records): + if self._matches(r, query): + del self.records[i] + return Mock(deleted_count=1) + return Mock(deleted_count=0) + def find(self, *args, **kwargs): query = args[0] if args else {} return FakeCursor( @@ -153,6 +161,7 @@ def __init__(self) -> None: self.integration_settings = FakeCollection() self.reminder_jobs = FakeCollection() self.reminder_alerts = FakeCollection() + self.locks = FakeCollection() class FakeMotorClient: