diff --git a/alembic.ini b/alembic.ini index 484de57fd..3acf3e9b7 100644 --- a/alembic.ini +++ b/alembic.ini @@ -60,7 +60,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne # are written from script.py.mako # output_encoding = utf-8 -sqlalchemy.url = +sqlalchemy.url = [post_write_hooks] diff --git a/api/v1/models/__init__.py b/api/v1/models/__init__.py index a39ec4bae..665d9d937 100644 --- a/api/v1/models/__init__.py +++ b/api/v1/models/__init__.py @@ -33,4 +33,4 @@ from api.v1.models.wishlist import Wishlist from api.v1.models.totp_device import TOTPDevice from api.v1.models.bookmark import Bookmark - +from api.v1.models.feature_request import FeatureRequest diff --git a/api/v1/models/feature_request.py b/api/v1/models/feature_request.py new file mode 100644 index 000000000..c5e6a4aae --- /dev/null +++ b/api/v1/models/feature_request.py @@ -0,0 +1,26 @@ +""" +Feature Request data model +""" + +from sqlalchemy import Column, String, text, Boolean, ForeignKey +from sqlalchemy.orm import relationship +from api.v1.models.base_model import BaseTableModel + + +class FeatureRequest(BaseTableModel): + __tablename__ = "feature_requests" + + title = Column(String, nullable=False) + description = Column(String, nullable=False) + priority = Column(String, server_default=text("'Low'")) # Low, Medium, High + status = Column(String, server_default=text("'Pending'")) # Pending, Approved, Rejected + is_deleted = Column(Boolean, server_default=text("false")) + + # Foreign Keys + user_id = Column(String, ForeignKey("users.id"), nullable=False) + + # Relationships + user = relationship("User", back_populates="feature_requests") + + def __str__(self): + return self.title \ No newline at end of file diff --git a/api/v1/models/user.py b/api/v1/models/user.py index 85ad5fa12..6e2d93142 100644 --- a/api/v1/models/user.py +++ b/api/v1/models/user.py @@ -117,6 +117,10 @@ class User(BaseTableModel): "Bookmark", back_populates="user", cascade="delete" ) + feature_requests = relationship( + "FeatureRequest", back_populates="user", cascade="all, delete-orphan" + ) + def to_dict(self): obj_dict = super().to_dict() obj_dict.pop("password") diff --git a/api/v1/routes/__init__.py b/api/v1/routes/__init__.py index fdd167d85..e246f34b6 100644 --- a/api/v1/routes/__init__.py +++ b/api/v1/routes/__init__.py @@ -47,6 +47,7 @@ from api.v1.routes.terms_and_conditions import terms_and_conditions from api.v1.routes.stripe import subscription_ from api.v1.routes.wishlist import wishlist +from api.v1.routes.feature_request import feature_request api_version_one = APIRouter(prefix="/api/v1") @@ -98,3 +99,4 @@ api_version_one.include_router(product_comment) api_version_one.include_router(subscription_) api_version_one.include_router(wishlist) +api_version_one.include_router(feature_request) diff --git a/api/v1/routes/feature_request.py b/api/v1/routes/feature_request.py new file mode 100644 index 000000000..32b1fa983 --- /dev/null +++ b/api/v1/routes/feature_request.py @@ -0,0 +1,134 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from api.db.database import get_db +from api.v1.schemas.feature_request import ( + FeatureRequestCreate, + FeatureRequestResponse, + FeatureRequestUpdate +) +from api.v1.services.feature_request import FeatureRequestService +from api.v1.services.user import user_service +from api.v1.models.user import User + +feature_request = APIRouter(prefix="/feature-request", tags=["Feature Requests"]) + + +@feature_request.post("/", response_model=FeatureRequestResponse, status_code=status.HTTP_201_CREATED) +def create_feature_request( + feature_request_data: FeatureRequestCreate, + db: Session = Depends(get_db), + current_user: User = Depends(user_service.get_current_user) +): + """ + Create a new feature request + """ + return FeatureRequestService.create_feature_request(db, feature_request_data, current_user.id) + + +@feature_request.get("/", response_model=List[FeatureRequestResponse]) +def get_feature_requests( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), + current_user: User = Depends(user_service.get_current_user) +): + """ + Get all feature requests + """ + # If user is a superadmin, return all feature requests + if current_user.is_superadmin: + return FeatureRequestService.get_feature_requests(db, skip, limit) + # Otherwise, return only the user's feature requests + return FeatureRequestService.get_user_feature_requests(db, current_user.id, skip, limit) + + +@feature_request.get("/{feature_request_id}", response_model=FeatureRequestResponse) +def get_feature_request( + feature_request_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(user_service.get_current_user) +): + """ + Get a feature request by ID + """ + feature_request = FeatureRequestService.get_feature_request_by_id(db, feature_request_id) + if not feature_request: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Feature request not found" + ) + + # Check if the user is allowed to access this feature request + if not current_user.is_superadmin and feature_request.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to access this feature request" + ) + + return feature_request + + +@feature_request.put("/{feature_request_id}", response_model=FeatureRequestResponse) +def update_feature_request( + feature_request_id: str, + feature_request_update: FeatureRequestUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(user_service.get_current_user) +): + """ + Update a feature request + """ + existing_feature_request = FeatureRequestService.get_feature_request_by_id(db, feature_request_id) + if not existing_feature_request: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Feature request not found" + ) + + # Check if the user is allowed to update this feature request + if not current_user.is_superadmin and existing_feature_request.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to update this feature request" + ) + + # Prevent non-superadmins from updating the status field + update_data = feature_request_update.dict(exclude_unset=True) + if not current_user.is_superadmin and "status" in update_data: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only admins can update the status field" + ) + + updated_feature_request = FeatureRequestService.update_feature_request( + db, feature_request_id, feature_request_update + ) + return updated_feature_request + + +@feature_request.delete("/{feature_request_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_feature_request( + feature_request_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(user_service.get_current_user) +): + """ + Delete a feature request + """ + existing_feature_request = FeatureRequestService.get_feature_request_by_id(db, feature_request_id) + if not existing_feature_request: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Feature request not found" + ) + + # Check if the user is allowed to delete this feature request + if not current_user.is_superadmin and existing_feature_request.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to delete this feature request" + ) + + FeatureRequestService.delete_feature_request(db, feature_request_id) + return None diff --git a/api/v1/schemas/feature_request.py b/api/v1/schemas/feature_request.py new file mode 100644 index 000000000..2eab2c3bd --- /dev/null +++ b/api/v1/schemas/feature_request.py @@ -0,0 +1,35 @@ +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, Field + + +class FeatureRequestBase(BaseModel): + title: str = Field(..., description="Title of the feature request") + description: str = Field(..., description="Detailed description of the requested feature") + priority: str = Field(default="Low", description="Priority level (Low, Medium, High)") + +class FeatureRequestCreate(FeatureRequestBase): + pass + + +class FeatureRequestUpdate(BaseModel): + title: Optional[str] = Field(None, description="Title of the feature request") + description: Optional[str] = Field(None, description="Detailed description of the requested feature") + priority: Optional[str] = Field(None, description="Priority level (Low, Medium, High)") + status: Optional[str] = Field(None, description="Status (Pending, Approved, Rejected) - can only be modified by admins") + + +class FeatureRequestInDB(FeatureRequestBase): + id: str + created_at: datetime + updated_at: datetime + user_id: str + status: str = "Pending" # Always included in DB model + is_deleted: bool = False + + class Config: + orm_mode = True + + +class FeatureRequestResponse(FeatureRequestInDB): + pass diff --git a/api/v1/services/feature_request.py b/api/v1/services/feature_request.py new file mode 100644 index 000000000..268f91857 --- /dev/null +++ b/api/v1/services/feature_request.py @@ -0,0 +1,54 @@ +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session +from api.v1.models.feature_request import FeatureRequest +from api.v1.schemas.feature_request import FeatureRequestCreate, FeatureRequestUpdate, FeatureRequestBase, FeatureRequestInDB, FeatureRequestResponse + + +class FeatureRequestService: + @staticmethod + def create_feature_request(db: Session, feature_request_data: FeatureRequestCreate, user_id: str): + # Convert to dict and explicitly set status to "Pending" + feature_request_dict = feature_request_data.dict() + feature_request_dict["status"] = "Pending" + + db_feature_request = FeatureRequest(**feature_request_data.dict(), user_id=user_id) + db.add(db_feature_request) + db.commit() + db.refresh(db_feature_request) + return db_feature_request + + @staticmethod + def get_feature_requests(db: Session, skip: int = 0, limit: int = 100): + return db.query(FeatureRequest).offset(skip).limit(limit).all() + + @staticmethod + def get_user_feature_requests(db: Session, user_id: str, skip: int = 0, limit: int = 100): + return db.query(FeatureRequest).filter(FeatureRequest.user_id == user_id).offset(skip).limit(limit).all() + + @staticmethod + def get_feature_request_by_id(db: Session, feature_request_id: str): + return db.query(FeatureRequest).filter(FeatureRequest.id == feature_request_id).first() + + @staticmethod + def update_feature_request(db: Session, feature_request_id: str, feature_request_update: FeatureRequestUpdate): + db_feature_request = db.query(FeatureRequest).filter(FeatureRequest.id == feature_request_id).first() + if db_feature_request: + for key, value in feature_request_update.dict(exclude_unset=True).items(): + setattr(db_feature_request, key, value) + db.commit() + db.refresh(db_feature_request) + return db_feature_request + + @staticmethod + def delete_feature_request(db: Session, feature_request_id: str): + db_feature_request = db.query(FeatureRequest).filter(FeatureRequest.id == feature_request_id).first() + if db_feature_request: + db.delete(db_feature_request) + db.commit() + return db_feature_request + + + class Config: + orm_mode = True diff --git a/test.db b/test.db new file mode 100644 index 000000000..1ce853260 Binary files /dev/null and b/test.db differ diff --git a/tests/v1/feature_request/test_create_feature_request.py b/tests/v1/feature_request/test_create_feature_request.py new file mode 100644 index 000000000..f46174f4b --- /dev/null +++ b/tests/v1/feature_request/test_create_feature_request.py @@ -0,0 +1,383 @@ +import uuid +from unittest.mock import MagicMock, patch +import pytest +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from api.v1.schemas.feature_request import FeatureRequestCreate, FeatureRequestResponse, FeatureRequestUpdate +from api.v1.routes.feature_request import create_feature_request, get_feature_requests, get_feature_request, update_feature_request, delete_feature_request + + +@pytest.fixture +def db_session(): + """Mock database session""" + return MagicMock(spec=Session) + + +@pytest.fixture +def sample_user(): + """Sample regular user""" + user = MagicMock() + user.id = str(uuid.uuid4()) + user.is_superadmin = False + return user + + +@pytest.fixture +def admin_user(): + """Sample admin user""" + user = MagicMock() + user.id = str(uuid.uuid4()) + user.is_superadmin = True + return user + + +@pytest.fixture +def feature_request_data(): + """Sample feature request data for testing""" + return FeatureRequestCreate( + title="Test Feature", + description="This is a test feature request", + priority="Low" # Changed from int to string + ) + + +@pytest.fixture +def feature_request_response(): + """Sample feature request response for testing""" + return FeatureRequestResponse( + id=str(uuid.uuid4()), + title="Test Feature", + description="This is a test feature request", + priority="Low", # Changed from int to string + status="Pending", + user_id=str(uuid.uuid4()), + created_at="2025-03-01T12:00:00", + updated_at="2025-03-01T12:00:00" + ) + + +class TestCreateFeatureRequest: + @patch("api.v1.services.feature_request.FeatureRequestService.create_feature_request") + def test_create_feature_request_success(self, mock_create, db_session, sample_user, feature_request_data): + # Arrange + mock_create.return_value = FeatureRequestResponse( + id=str(uuid.uuid4()), + title=feature_request_data.title, + description=feature_request_data.description, + priority=feature_request_data.priority, + status="Pending", + user_id=sample_user.id, + created_at="2025-03-01T12:00:00", + updated_at="2025-03-01T12:00:00" + ) + + # Act + result = create_feature_request(feature_request_data, db_session, sample_user) + + # Assert + mock_create.assert_called_once_with( + db_session, feature_request_data, sample_user.id + ) + assert result.title == feature_request_data.title + assert result.description == feature_request_data.description + assert result.priority == feature_request_data.priority + assert result.status == "Pending" # Verify status is Pending + assert result.user_id == sample_user.id + + +class TestGetFeatureRequests: + @patch("api.v1.services.feature_request.FeatureRequestService.get_feature_requests") + def test_get_feature_requests_as_admin(self, mock_get, db_session, admin_user): + # Arrange + mock_get.return_value = [ + FeatureRequestResponse( + id=str(uuid.uuid4()), + title="Feature 1", + description="Description 1", + priority="High", # Changed from int to string + status="Pending", + user_id=str(uuid.uuid4()), + created_at="2025-03-01T12:00:00", + updated_at="2025-03-01T12:00:00" + ), + FeatureRequestResponse( + id=str(uuid.uuid4()), + title="Feature 2", + description="Description 2", + priority="Medium", # Changed from int to string + status="Approved", + user_id=str(uuid.uuid4()), + created_at="2025-03-01T12:00:00", + updated_at="2025-03-01T12:00:00" + ) + ] + + # Act + result = get_feature_requests(0, 10, db_session, admin_user) + + # Assert + mock_get.assert_called_once_with(db_session, 0, 10) + assert len(result) == 2 + + @patch("api.v1.services.feature_request.FeatureRequestService.get_user_feature_requests") + def test_get_feature_requests_as_regular_user(self, mock_get_user, db_session, sample_user): + # Arrange + mock_get_user.return_value = [ + FeatureRequestResponse( + id=str(uuid.uuid4()), + title="User Feature", + description="User Description", + priority="Low", # Changed from int to string + status="Pending", + user_id=sample_user.id, + created_at="2025-03-01T12:00:00", + updated_at="2025-03-01T12:00:00" + ) + ] + + # Act + result = get_feature_requests(0, 10, db_session, sample_user) + + # Assert + mock_get_user.assert_called_once_with(db_session, sample_user.id, 0, 10) + assert len(result) == 1 + assert result[0].user_id == sample_user.id + + +class TestGetFeatureRequest: + @patch("api.v1.services.feature_request.FeatureRequestService.get_feature_request_by_id") + def test_get_feature_request_not_found(self, mock_get_by_id, db_session, sample_user): + # Arrange + feature_request_id = str(uuid.uuid4()) + mock_get_by_id.return_value = None + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + get_feature_request(feature_request_id, db_session, sample_user) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Feature request not found" + + @patch("api.v1.services.feature_request.FeatureRequestService.get_feature_request_by_id") + def test_get_feature_request_forbidden(self, mock_get_by_id, db_session, sample_user): + # Arrange + feature_request_id = str(uuid.uuid4()) + other_user_id = str(uuid.uuid4()) + + mock_feature_request = MagicMock() + mock_feature_request.user_id = other_user_id + + mock_get_by_id.return_value = mock_feature_request + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + get_feature_request(feature_request_id, db_session, sample_user) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "Not authorized to access this feature request" + + @patch("api.v1.services.feature_request.FeatureRequestService.get_feature_request_by_id") + def test_get_feature_request_success_owner(self, mock_get_by_id, db_session, sample_user): + # Arrange + feature_request_id = str(uuid.uuid4()) + + mock_feature_request = MagicMock() + mock_feature_request.user_id = sample_user.id + + mock_get_by_id.return_value = mock_feature_request + + # Act + result = get_feature_request(feature_request_id, db_session, sample_user) + + # Assert + assert result == mock_feature_request + + @patch("api.v1.services.feature_request.FeatureRequestService.get_feature_request_by_id") + def test_get_feature_request_success_admin(self, mock_get_by_id, db_session, admin_user): + # Arrange + feature_request_id = str(uuid.uuid4()) + other_user_id = str(uuid.uuid4()) + + mock_feature_request = MagicMock() + mock_feature_request.user_id = other_user_id + + mock_get_by_id.return_value = mock_feature_request + + # Act + result = get_feature_request(feature_request_id, db_session, admin_user) + + # Assert + assert result == mock_feature_request + + +class TestUpdateFeatureRequest: + @patch("api.v1.services.feature_request.FeatureRequestService.get_feature_request_by_id") + def test_update_feature_request_not_found(self, mock_get_by_id, db_session, sample_user): + # Arrange + feature_request_id = str(uuid.uuid4()) + update_data = FeatureRequestUpdate(title="Updated Title") + mock_get_by_id.return_value = None + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + update_feature_request(feature_request_id, update_data, db_session, sample_user) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Feature request not found" + + @patch("api.v1.services.feature_request.FeatureRequestService.get_feature_request_by_id") + def test_update_feature_request_forbidden(self, mock_get_by_id, db_session, sample_user): + # Arrange + feature_request_id = str(uuid.uuid4()) + update_data = FeatureRequestUpdate(title="Updated Title") + other_user_id = str(uuid.uuid4()) + + mock_feature_request = MagicMock() + mock_feature_request.user_id = other_user_id + + mock_get_by_id.return_value = mock_feature_request + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + update_feature_request(feature_request_id, update_data, db_session, sample_user) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "Not authorized to update this feature request" + + @patch("api.v1.services.feature_request.FeatureRequestService.get_feature_request_by_id") + def test_update_status_forbidden_for_regular_user(self, mock_get_by_id, db_session, sample_user): + # Arrange + feature_request_id = str(uuid.uuid4()) + update_data = FeatureRequestUpdate(status="Approved") # Try to update status + + mock_feature_request = MagicMock() + mock_feature_request.user_id = sample_user.id + + mock_get_by_id.return_value = mock_feature_request + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + update_feature_request(feature_request_id, update_data, db_session, sample_user) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "Only admins can update the status field" + + @patch("api.v1.services.feature_request.FeatureRequestService.get_feature_request_by_id") + @patch("api.v1.services.feature_request.FeatureRequestService.update_feature_request") + def test_update_status_allowed_for_admin(self, mock_update, mock_get_by_id, db_session, admin_user): + # Arrange + feature_request_id = str(uuid.uuid4()) + update_data = FeatureRequestUpdate(status="Approved") # Admin updating status + + mock_feature_request = MagicMock() + mock_feature_request.user_id = str(uuid.uuid4()) # Different user's request + + updated_feature_request = FeatureRequestResponse( + id=feature_request_id, + title="Original Title", + description="Original Description", + priority="medium", + status="Approved", # Status successfully updated + user_id=mock_feature_request.user_id, + created_at="2025-03-01T12:00:00", + updated_at="2025-03-01T12:30:00" + ) + + mock_get_by_id.return_value = mock_feature_request + mock_update.return_value = updated_feature_request + + # Act + result = update_feature_request(feature_request_id, update_data, db_session, admin_user) + + # Assert + mock_update.assert_called_once_with( + db_session, feature_request_id, update_data + ) + assert result.status == "Approved" # Status was updated + + @patch("api.v1.services.feature_request.FeatureRequestService.get_feature_request_by_id") + @patch("api.v1.services.feature_request.FeatureRequestService.update_feature_request") + def test_update_feature_request_success(self, mock_update, mock_get_by_id, db_session, sample_user): + # Arrange + feature_request_id = str(uuid.uuid4()) + update_data = FeatureRequestUpdate(title="Updated Title") + + mock_feature_request = MagicMock() + mock_feature_request.user_id = sample_user.id + + updated_feature_request = FeatureRequestResponse( + id=feature_request_id, + title="Updated Title", + description="Original Description", + priority="Medium", # Changed from int to string + status="Pending", # Status unchanged + user_id=sample_user.id, + created_at="2025-03-01T12:00:00", + updated_at="2025-03-01T12:30:00" + ) + + mock_get_by_id.return_value = mock_feature_request + mock_update.return_value = updated_feature_request + + # Act + result = update_feature_request(feature_request_id, update_data, db_session, sample_user) + + # Assert + mock_update.assert_called_once_with( + db_session, feature_request_id, update_data + ) + assert result.title == "Updated Title" + assert result.user_id == sample_user.id + assert result.status == "Pending" # Status remains unchanged + + +class TestDeleteFeatureRequest: + @patch("api.v1.services.feature_request.FeatureRequestService.get_feature_request_by_id") + def test_delete_feature_request_not_found(self, mock_get_by_id, db_session, sample_user): + # Arrange + feature_request_id = str(uuid.uuid4()) + mock_get_by_id.return_value = None + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + delete_feature_request(feature_request_id, db_session, sample_user) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Feature request not found" + + @patch("api.v1.services.feature_request.FeatureRequestService.get_feature_request_by_id") + def test_delete_feature_request_forbidden(self, mock_get_by_id, db_session, sample_user): + # Arrange + feature_request_id = str(uuid.uuid4()) + other_user_id = str(uuid.uuid4()) + + mock_feature_request = MagicMock() + mock_feature_request.user_id = other_user_id + + mock_get_by_id.return_value = mock_feature_request + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + delete_feature_request(feature_request_id, db_session, sample_user) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "Not authorized to delete this feature request" + + @patch("api.v1.services.feature_request.FeatureRequestService.get_feature_request_by_id") + @patch("api.v1.services.feature_request.FeatureRequestService.delete_feature_request") + def test_delete_feature_request_success(self, mock_delete, mock_get_by_id, db_session, sample_user): + # Arrange + feature_request_id = str(uuid.uuid4()) + + mock_feature_request = MagicMock() + mock_feature_request.user_id = sample_user.id + + mock_get_by_id.return_value = mock_feature_request + + # Act + result = delete_feature_request(feature_request_id, db_session, sample_user) + + # Assert + mock_delete.assert_called_once_with(db_session, feature_request_id) + assert result is None