diff --git a/backend/api/resource.py b/backend/api/resource.py new file mode 100644 index 0000000..98dcaae --- /dev/null +++ b/backend/api/resource.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter, Depends +from ..services import ResourceService, UserService +from ..models.resource_model import Resource + +from typing import List + +api = APIRouter(prefix="/api/resource") + +openapi_tags = { + "name": "Resource", + "description": "Resource search and related operations.", +} + + +# TODO: Add security using HTTP Bearer Tokens +# TODO: Enable authorization by passing user uuid to API +# TODO: Create custom exceptions +@api.get("", response_model=List[Resource], tags=["Resource"]) +def get_all( + user_id: str, + resource_svc: ResourceService = Depends(), + user_svc: UserService = Depends(), +): + subject = user_svc.get_user_by_uuid(user_id) + + return resource_svc.get_resource_by_user(subject) diff --git a/backend/entities/resource_entity.py b/backend/entities/resource_entity.py index cbe5d7c..c11514b 100644 --- a/backend/entities/resource_entity.py +++ b/backend/entities/resource_entity.py @@ -15,6 +15,7 @@ from datetime import datetime # Import self for to model from typing import Self from backend.entities.program_enum import Program_Enum +from ..models.resource_model import Resource class ResourceEntity(EntityBase): @@ -34,34 +35,33 @@ class ResourceEntity(EntityBase): back_populates="resource", cascade="all,delete" ) - # - # @classmethod - # def from_model(cls, model: user_model) -> Self: - # """ - # Create a UserEntity from a User model. + @classmethod + def from_model(cls, model: Resource) -> Self: + """ + Create a UserEntity from a User model. - # Args: - # model (User): The model to create the entity from. + Args: + model (User): The model to create the entity from. - # Returns: - # Self: The entity (not yet persisted). - # """ + Returns: + Self: The entity (not yet persisted). + """ - # return cls ( - # id = model.id, - # created_at = model.created_at, - # name = model.name, - # summary = model.summary, - # link = model.link, - # program = model.program, - # ) + return cls( + id=model.id, + created_at=model.created_at, + name=model.name, + summary=model.summary, + link=model.link, + program=model.program, + ) - # def to_model(self) -> user_model: - # return user_model ( - # id = self.id, - # created_at = self.created_at, - # name = self.name, - # summary = self.summary, - # link = self.link, - # program = self.program, - # ) + def to_model(self) -> Resource: + return Resource( + id=self.id, + created_at=self.created_at, + name=self.name, + summary=self.summary, + link=self.link, + program=self.program, + ) diff --git a/backend/main.py b/backend/main.py index 4c9c6e0..d8af3ff 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from fastapi.middleware.gzip import GZipMiddleware -from .api import user, health, service +from .api import user, health, service, resource description = """ Welcome to the **COMPASS** RESTful Application Programming Interface. @@ -12,12 +12,17 @@ app = FastAPI( title="Compass API", version="0.0.1", description=description, - openapi_tags=[user.openapi_tags, health.openapi_tags, service.openapi_tags], + openapi_tags=[ + user.openapi_tags, + health.openapi_tags, + service.openapi_tags, + resource.openapi_tags, + ], ) app.add_middleware(GZipMiddleware) -feature_apis = [user, health, service] +feature_apis = [user, health, service, resource] for feature_api in feature_apis: app.include_router(feature_api.api) diff --git a/backend/models/resource_model.py b/backend/models/resource_model.py index 8601c41..8c9fde0 100644 --- a/backend/models/resource_model.py +++ b/backend/models/resource_model.py @@ -4,7 +4,6 @@ from typing import List from datetime import datetime from typing import Optional from .enum_for_models import ProgramTypeEnum -from .resource_model import Resource class Resource(BaseModel): @@ -12,5 +11,5 @@ class Resource(BaseModel): name: str = Field(..., max_length=150, description="The name of the resource") summary: str = Field(..., max_length=300, description="The summary of the resource") link: str = Field(..., max_length=150, description="link to the resource") - programtype: ProgramTypeEnum + program: ProgramTypeEnum created_at: Optional[datetime] diff --git a/backend/script/reset_demo.py b/backend/script/reset_demo.py index 16f3871..7c9f36e 100644 --- a/backend/script/reset_demo.py +++ b/backend/script/reset_demo.py @@ -6,7 +6,7 @@ from ..database import engine, _engine_str from ..env import getenv from .. import entities -from ..test.services import user_test_data, service_test_data +from ..test.services import user_test_data, service_test_data, resource_test_data database = getenv("POSTGRES_DATABASE") @@ -23,5 +23,5 @@ entities.EntityBase.metadata.create_all(engine) with Session(engine) as session: user_test_data.insert_test_data(session) service_test_data.insert_fake_data(session) - + resource_test_data.insert_fake_data(session) session.commit() diff --git a/backend/services/resource.py b/backend/services/resource.py index 9bbe3ac..2648ad6 100644 --- a/backend/services/resource.py +++ b/backend/services/resource.py @@ -4,33 +4,33 @@ from sqlalchemy.orm import Session from sqlalchemy import select from ..models.resource_model import Resource from ..entities.resource_entity import ResourceEntity -from ..models.user_model import User +from ..models.user_model import User, UserTypeEnum from .exceptions import ResourceNotFoundException + class ResourceService: def __init__(self, session: Session = Depends(db_session)): self._session = session - def all(self, user: User) -> list[Resource]: - """ - Retrieves all Resources that the user has access to based on their role and group. + def get_resource_by_user(self, subject: User): + """Resource method getting all of the resources that a user has access to based on role""" + if subject.role != UserTypeEnum.VOLUNTEER: + query = select(ResourceEntity) + entities = self._session.scalars(query).all() - Parameters: - user: a valid User model representing the currently logged in User + return [resource.to_model() for resource in entities] + else: + programs = subject.program + resources = [] + for program in programs: + query = select(ResourceEntity).filter(ResourceEntity.program == program) + entities = self._session.scalars(query).all() + for entity in entities: + resources.append(entity) - Returns: - list[Resource]: list of accessible `Resource` for the user - """ - # Filter resources based on user's role and group - query = select(ResourceEntity).where( - ResourceEntity.role == user.role, - ResourceEntity.group == user.group - ) - entities = self._session.scalars(query).all() - - return [entity.to_model() for entity in entities] + return [resource.to_model() for resource in resources] def create(self, user: User, resource: Resource) -> Resource: """ @@ -44,7 +44,9 @@ class ResourceService: Resource: Object added to table """ if resource.role != user.role or resource.group != user.group: - raise PermissionError("User does not have permission to add resources in this role or group.") + raise PermissionError( + "User does not have permission to add resources in this role or group." + ) resource_entity = ResourceEntity.from_model(resource) self._session.add(resource_entity) @@ -68,7 +70,11 @@ class ResourceService: """ resource = ( self._session.query(ResourceEntity) - .filter(ResourceEntity.id == id, ResourceEntity.role == user.role, ResourceEntity.group == user.group) + .filter( + ResourceEntity.id == id, + ResourceEntity.role == user.role, + ResourceEntity.group == user.group, + ) .one_or_none() ) @@ -92,12 +98,16 @@ class ResourceService: ResourceNotFoundException: If no resource is found with the corresponding ID """ if resource.role != user.role or resource.group != user.group: - raise PermissionError("User does not have permission to update this resource.") + raise PermissionError( + "User does not have permission to update this resource." + ) obj = self._session.get(ResourceEntity, resource.id) if resource.id else None if obj is None: - raise ResourceNotFoundException(f"No resource found with matching id: {resource.id}") + raise ResourceNotFoundException( + f"No resource found with matching id: {resource.id}" + ) obj.update_from_model(resource) # Assuming an update method exists self._session.commit() @@ -117,7 +127,11 @@ class ResourceService: """ resource = ( self._session.query(ResourceEntity) - .filter(ResourceEntity.id == id, ResourceEntity.role == user.role, ResourceEntity.group == user.group) + .filter( + ResourceEntity.id == id, + ResourceEntity.role == user.role, + ResourceEntity.group == user.group, + ) .one_or_none() ) @@ -144,7 +158,7 @@ class ResourceService: query = select(ResourceEntity).where( ResourceEntity.title.ilike(f"%{search_string}%"), ResourceEntity.role == user.role, - ResourceEntity.group == user.group + ResourceEntity.group == user.group, ) entities = self._session.scalars(query).all() diff --git a/backend/test/services/resource_test.py b/backend/test/services/resource_test.py deleted file mode 100644 index 67457d2..0000000 --- a/backend/test/services/resource_test.py +++ /dev/null @@ -1,107 +0,0 @@ -import pytest -from sqlalchemy.orm import Session -from sqlalchemy.exc import NoResultFound -from .resource_service import ResourceService -from .models.resource_model import Resource -from .entities.resource_entity import ResourceEntity -from .models.user_model import User -from .exceptions import ResourceNotFoundException - -# Example of a Resource and User object creation for use in tests -@pytest.fixture -def sample_resource(): - return Resource(id=1, name="Sample Resource", summary="A brief summary", link="http://example.com", programtype="TypeA") - -@pytest.fixture -def sample_user(): - return User(id=1, username="admin", is_admin=True) - -@pytest.fixture -def resource_service(mocker): - # Mock the session and its methods - mock_session = mocker.MagicMock(spec=Session) - return ResourceService(session=mock_session) - -def test_all(resource_service, mocker): - # Setup - mock_query_all = mocker.MagicMock(return_value=[ResourceEntity(id=1, name="Resource One"), ResourceEntity(id=2, name="Resource Two")]) - mocker.patch.object(resource_service._session, 'scalars', return_value=mock_query_all) - - # Execution - results = resource_service.all() - - # Verification - assert len(results) == 2 - assert results[0].id == 1 - assert results[1].name == "Resource Two" - -def test_create(resource_service, mocker, sample_resource, sample_user): - # Mock the add and commit methods of session - mocker.patch.object(resource_service._session, 'add') - mocker.patch.object(resource_service._session, 'commit') - - # Execution - result = resource_service.create(sample_user, sample_resource) - - # Verification - resource_service._session.add.assert_called_once() - resource_service._session.commit.assert_called_once() - assert result.id == sample_resource.id - assert result.name == sample_resource.name - -def test_get_by_id_found(resource_service, mocker): - # Setup - resource_entity = ResourceEntity(id=1, name="Existing Resource") - mocker.patch.object(resource_service._session, 'query', return_value=mocker.MagicMock(one_or_none=mocker.MagicMock(return_value=resource_entity))) - - # Execution - result = resource_service.get_by_id(1) - - # Verification - assert result.id == 1 - assert result.name == "Existing Resource" - -def test_get_by_id_not_found(resource_service, mocker): - # Setup - mocker.patch.object(resource_service._session, 'query', return_value=mocker.MagicMock(one_or_none=mocker.MagicMock(return_value=None))) - - # Execution & Verification - with pytest.raises(ResourceNotFoundException): - resource_service.get_by_id(999) - -def test_update(resource_service, mocker, sample_resource, sample_user): - # Setup - mocker.patch.object(resource_service._session, 'get', return_value=sample_resource) - mocker.patch.object(resource_service._session, 'commit') - - # Execution - result = resource_service.update(sample_user, sample_resource) - - # Verification - assert result.id == sample_resource.id - resource_service._session.commit.assert_called_once() - -def test_delete(resource_service, mocker): - # Setup - mock_resource = ResourceEntity(id=1, name="Delete Me") - mocker.patch.object(resource_service._session, 'query', return_value=mocker.MagicMock(one_or_none=mocker.MagicMock(return_value=mock_resource))) - mocker.patch.object(resource_service._session, 'delete') - mocker.patch.object(resource_service._session, 'commit') - - # Execution - resource_service.delete(sample_user(), 1) - - # Verification - resource_service._session.delete.assert_called_with(mock_resource) - resource_service._session.commit.assert_called_once() - -def test_get_by_slug(resource_service, mocker): - # Setup - mock_query_all = mocker.MagicMock(return_value=[ResourceEntity(id=1, name="Resource One"), ResourceEntity(id=2, name="Resource Two")]) - mocker.patch.object(resource_service._session, 'scalars', return_value=mock_query_all) - - # Execution - results = resource_service.get_by_slug("Resource") - - # Verification - diff --git a/backend/test/services/resource_test_data.py b/backend/test/services/resource_test_data.py new file mode 100644 index 0000000..c20aa59 --- /dev/null +++ b/backend/test/services/resource_test_data.py @@ -0,0 +1,95 @@ +from sqlalchemy.orm import Session +from datetime import datetime + +from ...entities import ResourceEntity +from ...models.enum_for_models import ProgramTypeEnum +from ...models.resource_model import Resource + +resource_1 = Resource( + id=1, + name="Resource 1", + summary="Helpful information for victims of domestic violence", + link="https://example.com/resource1", + program=ProgramTypeEnum.DOMESTIC, + created_at=datetime(2023, 6, 1, 10, 0, 0), +) + +resource_2 = Resource( + id=2, + name="Resource 2", + summary="Legal assistance resources", + link="https://example.com/resource2", + program=ProgramTypeEnum.COMMUNITY, + created_at=datetime(2023, 6, 2, 12, 30, 0), +) + +resource_3 = Resource( + id=3, + name="Resource 3", + summary="Financial aid resources", + link="https://example.com/resource3", + program=ProgramTypeEnum.ECONOMIC, + created_at=datetime(2023, 6, 3, 15, 45, 0), +) + +resource_4 = Resource( + id=4, + name="Resource 4", + summary="Counseling and support groups", + link="https://example.com/resource4", + program=ProgramTypeEnum.DOMESTIC, + created_at=datetime(2023, 6, 4, 9, 15, 0), +) + +resource_5 = Resource( + id=5, + name="Resource 5", + summary="Shelter and housing resources", + link="https://example.com/resource5", + program=ProgramTypeEnum.DOMESTIC, + created_at=datetime(2023, 6, 5, 11, 30, 0), +) + +resources = [resource_1, resource_2, resource_3, resource_4, resource_5] + +from sqlalchemy import text +from sqlalchemy.orm import Session, DeclarativeBase, InstrumentedAttribute + + +def reset_table_id_seq( + session: Session, + entity: type[DeclarativeBase], + entity_id_column: InstrumentedAttribute[int], + next_id: int, +) -> None: + """Reset the ID sequence of an entity table. + + Args: + session (Session) - A SQLAlchemy Session + entity (DeclarativeBase) - The SQLAlchemy Entity table to target + entity_id_column (MappedColumn) - The ID column (should be an int column) + next_id (int) - Where the next inserted, autogenerated ID should begin + + Returns: + None""" + table = entity.__table__ + id_column_name = entity_id_column.name + sql = text(f"ALTER SEQUENCe {table}_{id_column_name}_seq RESTART WITH {next_id}") + session.execute(sql) + + +def insert_fake_data(session: Session): + """Inserts fake resource data into the test session.""" + global resources + # Create entities for test resource data + entities = [] + for resource in resources: + entity = ResourceEntity.from_model(resource) + session.add(entity) + entities.append(entity) + + # Reset table IDs to prevent ID conflicts + reset_table_id_seq(session, ResourceEntity, ResourceEntity.id, len(resources) + 1) + + # Commit all changes + session.commit()