-
Notifications
You must be signed in to change notification settings - Fork 37
Refactor data models and improve API robustness #473
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
cf10103
c149222
198a0ba
8b4a63d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -662,7 +662,7 @@ def get_recent_issues( | |
| cache_key = f"recent_issues_{limit}_{offset}" | ||
| cached_data = recent_issues_cache.get(cache_key) | ||
| if cached_data: | ||
| return JSONResponse(content=cached_data) | ||
| return cached_data | ||
|
Comment on lines
663
to
+665
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# First, find the cache implementation
fd -t f "cache.py" backend/ 2>/dev/null | head -20Repository: RohanExploit/VishwaGuru Length of output: 83 🏁 Script executed: #!/bin/bash
# Check the lines in question from the issues.py file
cat -n backend/routers/issues.py | sed -n '670,715p'Repository: RohanExploit/VishwaGuru Length of output: 1825 🏁 Script executed: #!/bin/bash
# Search for the cache.get() method definition
rg -n "def get" backend/ -A 5 --type py | grep -A 5 "cache"Repository: RohanExploit/VishwaGuru Length of output: 3137 🏁 Script executed: #!/bin/bash
# Read the complete cache.py to understand get() return behavior
cat -n backend/cache.pyRepository: RohanExploit/VishwaGuru Length of output: 6516
When the DB legitimately returns zero issues, 🐛 Proposed fix- cached_data = recent_issues_cache.get(cache_key)
- if cached_data:
- return cached_data
+ cached_data = recent_issues_cache.get(cache_key)
+ if cached_data is not None:
+ return cached_data🤖 Prompt for AI Agents |
||
|
|
||
| # Fetch issues with pagination | ||
| # Optimized: Use column projection to fetch only needed fields | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| import sys | ||
| from unittest.mock import MagicMock | ||
|
|
||
| # Create dummy classes for types used in isinstance/issubclass checks | ||
| class MockTensor: pass | ||
|
|
||
| mock_torch = MagicMock() | ||
| mock_torch.Tensor = MockTensor | ||
| sys.modules["torch"] = mock_torch | ||
|
|
||
| sys.modules["google"] = MagicMock() | ||
| sys.modules["google.generativeai"] = MagicMock() | ||
| sys.modules["ultralytics"] = MagicMock() | ||
| sys.modules["transformers"] = MagicMock() | ||
| sys.modules["telegram"] = MagicMock() | ||
| sys.modules["telegram.ext"] = MagicMock() | ||
| sys.modules["speech_recognition"] = MagicMock() | ||
| sys.modules["a2wsgi"] = MagicMock() | ||
| sys.modules["firebase_functions"] = MagicMock() | ||
| sys.modules["googletrans"] = MagicMock() | ||
| sys.modules["langdetect"] = MagicMock() | ||
|
|
||
| import pytest | ||
| from unittest.mock import MagicMock, patch | ||
| from fastapi.responses import JSONResponse | ||
| # We need to ensure we import these AFTER mocking | ||
| from backend.routers.issues import get_recent_issues, create_issue | ||
| from backend.cache import recent_issues_cache | ||
| import os | ||
| import shutil | ||
|
|
||
| # Test get_recent_issues return type | ||
| def test_get_recent_issues_return_type(): | ||
| # Mock cache | ||
| mock_data = [{"id": 1, "description": "test"}] | ||
| recent_issues_cache.set(mock_data, "recent_issues_10_0") | ||
|
|
||
| # Mock DB | ||
| db = MagicMock() | ||
|
|
||
| # Call function | ||
| response = get_recent_issues(limit=10, offset=0, db=db) | ||
|
|
||
| # Check that response is NOT a JSONResponse, but the data itself | ||
| assert not isinstance(response, JSONResponse) | ||
| assert response == mock_data | ||
| assert isinstance(response, list) | ||
|
|
||
| # Test create_issue cleanup | ||
| @pytest.mark.asyncio | ||
| async def test_create_issue_cleanup(): | ||
| # Mock dependencies | ||
| request = MagicMock() | ||
| background_tasks = MagicMock() | ||
| db = MagicMock() | ||
|
|
||
| # Mock file upload | ||
| image = MagicMock() | ||
| image.filename = "test.jpg" | ||
|
|
||
| # Mock process_uploaded_image to return dummy data | ||
| with patch("backend.routers.issues.process_uploaded_image") as mock_process: | ||
| mock_process.return_value = (MagicMock(), b"fake_bytes") | ||
|
|
||
| # Mock save_processed_image to create a dummy file | ||
| with patch("backend.routers.issues.save_processed_image") as mock_save_image: | ||
| def side_effect(bytes_data, path): | ||
| # Create directory if needed | ||
| os.makedirs(os.path.dirname(path), exist_ok=True) | ||
| with open(path, "wb") as f: | ||
| f.write(bytes_data) | ||
| mock_save_image.side_effect = side_effect | ||
|
|
||
| # Mock save_issue_db to raise exception | ||
| with patch("backend.routers.issues.save_issue_db") as mock_save_db: | ||
| mock_save_db.side_effect = Exception("DB Error") | ||
|
|
||
| # Mock spatial utils | ||
| with patch("backend.routers.issues.get_bounding_box") as mock_bbox: | ||
| mock_bbox.return_value = (0, 0, 0, 0) | ||
| with patch("backend.routers.issues.find_nearby_issues") as mock_nearby: | ||
| mock_nearby.return_value = [] | ||
|
|
||
| # Mock rag_service | ||
| with patch("backend.routers.issues.rag_service") as mock_rag: | ||
| mock_rag.retrieve.return_value = None | ||
|
|
||
| # Call create_issue | ||
| try: | ||
| await create_issue( | ||
| request=request, | ||
| background_tasks=background_tasks, | ||
| description="Test description length check", | ||
| category="Road", | ||
| language="en", | ||
| user_email="test@example.com", | ||
| latitude=10.0, | ||
| longitude=10.0, | ||
| location="Test Loc", | ||
| image=image, | ||
| db=db | ||
| ) | ||
| except Exception as e: | ||
| assert "Failed to save issue to database" in str(e) | ||
|
Comment on lines
+89
to
+104
|
||
|
|
||
| # Check if file was cleaned up | ||
| args, _ = mock_save_image.call_args | ||
| file_path = args[1] | ||
|
|
||
| assert not os.path.exists(file_path), f"File {file_path} should have been deleted" | ||
|
Comment on lines
+50
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Additionally, the 🐛 Proposed fix — mock `check_upload_limits` and add cleanup+import shutil
`@pytest.mark.asyncio`
async def test_create_issue_cleanup():
request = MagicMock()
background_tasks = MagicMock()
db = MagicMock()
image = MagicMock()
image.filename = "test.jpg"
+ upload_dir = "data/uploads"
with patch("backend.routers.issues.process_uploaded_image") as mock_process:
mock_process.return_value = (MagicMock(), b"fake_bytes")
with patch("backend.routers.issues.save_processed_image") as mock_save_image:
def side_effect(bytes_data, path):
- os.makedirs(os.path.dirname(path), exist_ok=True)
+ os.makedirs(upload_dir, exist_ok=True)
with open(path, "wb") as f:
f.write(bytes_data)
mock_save_image.side_effect = side_effect
with patch("backend.routers.issues.save_issue_db") as mock_save_db:
mock_save_db.side_effect = Exception("DB Error")
with patch("backend.routers.issues.get_bounding_box") as mock_bbox:
mock_bbox.return_value = (0, 0, 0, 0)
with patch("backend.routers.issues.find_nearby_issues") as mock_nearby:
mock_nearby.return_value = []
with patch("backend.routers.issues.rag_service") as mock_rag:
+ with patch("backend.routers.issues.check_upload_limits"):
mock_rag.retrieve.return_value = None
- try:
- await create_issue(...)
- except Exception as e:
- assert "Failed to save issue to database" in str(e)
-
- args, _ = mock_save_image.call_args
- file_path = args[1]
- assert not os.path.exists(file_path), ...
+ try:
+ await create_issue(
+ request=request,
+ background_tasks=background_tasks,
+ description="Test description length check",
+ category="Road",
+ language="en",
+ user_email="test@example.com",
+ latitude=10.0,
+ longitude=10.0,
+ location="Test Loc",
+ image=image,
+ db=db
+ )
+ except Exception as e:
+ assert "Failed to save issue to database" in str(e)
+ finally:
+ # Cleanup test artifacts
+ if os.path.exists(upload_dir):
+ shutil.rmtree(upload_dir)
+
+ args, _ = mock_save_image.call_args
+ file_path = args[1]
+ assert not os.path.exists(file_path), \
+ f"File {file_path} should have been deleted"🧰 Tools🪛 Ruff (0.15.2)[warning] 103-103: Do not catch blind exception: (BLE001) 🤖 Prompt for AI Agents |
||
|
|
||
| # Test get_recent_issues when not cached | ||
| def test_get_recent_issues_uncached(): | ||
| # Clear cache | ||
| recent_issues_cache.clear() | ||
|
|
||
| # Mock DB | ||
| db = MagicMock() | ||
| # Mock query result - create a Mock object that behaves like the row | ||
| mock_row = MagicMock() | ||
| mock_row.id = 1 | ||
| mock_row.description = "test" | ||
| mock_row.category = "Road" | ||
| mock_row.created_at = MagicMock() | ||
| mock_row.created_at.isoformat.return_value = "2023-01-01" | ||
| mock_row.image_path = "img.jpg" | ||
| mock_row.status = "open" | ||
| mock_row.upvotes = 0 | ||
| mock_row.location = "Loc" | ||
| mock_row.latitude = 10.0 | ||
| mock_row.longitude = 10.0 | ||
|
|
||
| # Setup chain of calls: db.query(...).order_by(...).offset(...).limit(...).all() | ||
| # Note: query() returns a Query object. | ||
| mock_query = MagicMock() | ||
| db.query.return_value = mock_query | ||
| mock_query.order_by.return_value = mock_query | ||
| mock_query.offset.return_value = mock_query | ||
| mock_query.limit.return_value = mock_query | ||
| mock_query.all.return_value = [mock_row] | ||
|
|
||
| # Call function | ||
| response = get_recent_issues(limit=10, offset=0, db=db) | ||
|
|
||
| assert isinstance(response, list) | ||
| assert len(response) == 1 | ||
| assert response[0]["id"] == 1 | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -159,54 +159,22 @@ def process_uploaded_image_sync(file: UploadFile) -> tuple[Image.Image, bytes]: | |||||||||||||||||
| Synchronously validate, resize, and strip EXIF from uploaded image. | ||||||||||||||||||
| Returns a tuple of (PIL Image, image bytes). | ||||||||||||||||||
| """ | ||||||||||||||||||
| # Check file size | ||||||||||||||||||
| file.file.seek(0, 2) | ||||||||||||||||||
| file_size = file.file.tell() | ||||||||||||||||||
| file.file.seek(0) | ||||||||||||||||||
|
|
||||||||||||||||||
| if file_size > MAX_FILE_SIZE: | ||||||||||||||||||
| raise HTTPException( | ||||||||||||||||||
| status_code=413, | ||||||||||||||||||
| detail=f"File too large. Maximum size allowed is {MAX_FILE_SIZE // (1024*1024)}MB" | ||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
| # Check MIME type if magic is available | ||||||||||||||||||
| if HAS_MAGIC: | ||||||||||||||||||
| try: | ||||||||||||||||||
| file_content = file.file.read(1024) | ||||||||||||||||||
| file.file.seek(0) | ||||||||||||||||||
| detected_mime = magic.from_buffer(file_content, mime=True) | ||||||||||||||||||
|
|
||||||||||||||||||
| if detected_mime not in ALLOWED_MIME_TYPES: | ||||||||||||||||||
| raise HTTPException( | ||||||||||||||||||
| status_code=400, | ||||||||||||||||||
| detail=f"Invalid file type. Only image files are allowed. Detected: {detected_mime}" | ||||||||||||||||||
| ) | ||||||||||||||||||
| except Exception as e: | ||||||||||||||||||
| logger.error(f"Magic check failed: {e}") | ||||||||||||||||||
| pass | ||||||||||||||||||
| # Use existing validation logic (which handles size limits and basic validation) | ||||||||||||||||||
| img = _validate_uploaded_file_sync(file) | ||||||||||||||||||
|
Comment on lines
+162
to
+163
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unguarded dereference if The return type of 🛡️ Proposed defensive guard img = _validate_uploaded_file_sync(file)
+ if img is None:
+ raise HTTPException(status_code=400, detail="Failed to process image file.")📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| try: | ||||||||||||||||||
| img = Image.open(file.file) | ||||||||||||||||||
| original_format = img.format | ||||||||||||||||||
|
|
||||||||||||||||||
| # Resize if needed | ||||||||||||||||||
| if img.width > 1024 or img.height > 1024: | ||||||||||||||||||
| ratio = min(1024 / img.width, 1024 / img.height) | ||||||||||||||||||
| new_width = int(img.width * ratio) | ||||||||||||||||||
| new_height = int(img.height * ratio) | ||||||||||||||||||
| img = img.resize((new_width, new_height), Image.Resampling.BILINEAR) | ||||||||||||||||||
|
|
||||||||||||||||||
| # Strip EXIF | ||||||||||||||||||
| img_no_exif = Image.new(img.mode, img.size) | ||||||||||||||||||
| img_no_exif.paste(img) | ||||||||||||||||||
|
Comment on lines
+163
to
168
|
||||||||||||||||||
|
|
||||||||||||||||||
| # Save to BytesIO | ||||||||||||||||||
| output = io.BytesIO() | ||||||||||||||||||
| # Preserve format or default to JPEG (handling mode compatibility) | ||||||||||||||||||
| # JPEG doesn't support RGBA, so use PNG for RGBA if format not specified | ||||||||||||||||||
| if original_format: | ||||||||||||||||||
| fmt = original_format | ||||||||||||||||||
| # Preserve format or default to JPEG/PNG based on mode | ||||||||||||||||||
| # _validate_uploaded_file_sync doesn't return the format explicitly if resized, | ||||||||||||||||||
| # but img.format is None if resized. | ||||||||||||||||||
| # If not resized, img.format is available. | ||||||||||||||||||
| if img.format: | ||||||||||||||||||
| fmt = img.format | ||||||||||||||||||
| else: | ||||||||||||||||||
| fmt = 'PNG' if img.mode == 'RGBA' else 'JPEG' | ||||||||||||||||||
|
Comment on lines
+176
to
179
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Defaulting to JPEG when img.format is missing breaks palette-mode images (e.g., GIF/PNG with mode "P") after resize, because PIL can’t save "P" as JPEG. This causes valid uploads to fail. Treat non-JPEG-compatible modes as PNG (or convert to RGB) when img.format is unavailable. Prompt for AI agents
Suggested change
|
||||||||||||||||||
|
|
||||||||||||||||||
|
|
@@ -215,11 +183,11 @@ def process_uploaded_image_sync(file: UploadFile) -> tuple[Image.Image, bytes]: | |||||||||||||||||
|
|
||||||||||||||||||
| return img_no_exif, img_bytes | ||||||||||||||||||
|
|
||||||||||||||||||
| except Exception as pil_error: | ||||||||||||||||||
| logger.error(f"PIL processing failed: {pil_error}") | ||||||||||||||||||
| except Exception as e: | ||||||||||||||||||
| logger.error(f"Image processing failed: {e}") | ||||||||||||||||||
| raise HTTPException( | ||||||||||||||||||
| status_code=400, | ||||||||||||||||||
| detail="Invalid image file." | ||||||||||||||||||
| detail="Failed to process image file." | ||||||||||||||||||
| ) | ||||||||||||||||||
|
|
||||||||||||||||||
| async def process_uploaded_image(file: UploadFile) -> tuple[Image.Image, bytes]: | ||||||||||||||||||
|
|
||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: RohanExploit/VishwaGuru
Length of output: 538
🏁 Script executed:
Repository: RohanExploit/VishwaGuru
Length of output: 150
🌐 Web query:
latest stable bcrypt version PyPI passlib compatibility 2025 2026💡 Result:
bcrypton PyPI (as of Feb 25, 2026):bcrypt 5.0.0(released Sep 25, 2025). [1]passlibon PyPI:passlib 1.7.4(released Oct 8, 2020). [2]Compatibility (Passlib + bcrypt, 2025–2026 reality):
bcrypt 4.1.0was yanked on PyPI explicitly due to “Incompatibility with assumptions made by passlib.” [3]bcryptto<= 4.0.1to keep Passlib working. [4]Practical guidance: if you must stay on
passlib==1.7.4, expect to pinbcrypt(often4.0.1) rather than using the latestbcrypt(5.0.0). [2][4]Sources
Pin
bcryptto a compatible version withpasslib[bcrypt].Latest
bcryptversions (4.1.0+, 5.0.0) are incompatible withpasslib1.7.4—bcrypt 4.1.0 was yanked due to breaking passlib's version detection. Pinbcrypt<=4.0.1to ensure runtime compatibility and reproducible deployments. Additionally, pinpasslib[bcrypt]to avoid unexpected major version changes.Proposed change
🤖 Prompt for AI Agents