Compare commits

..

5 Commits

Author SHA1 Message Date
Andy Chan
1c21b4377b
Merge 51c40684fa into 354946e19e 2024-11-05 19:49:27 -05:00
pmoharana-cmd
354946e19e Hotfix for Next.js route query paramater 2024-11-05 19:21:42 -05:00
Aidan Kim
7d705ac743
API Routes for Resources and Services (#40)
* Implemented API routes for getting all, creating, updating, and deleting resources, services, and tags.

* Updated main.py for API routes to include tags and rolled entities back.

* Created API routes for create, update, delete, get_all, and get_by_name. Deleted service methods for get by id.

* Defaults created_at to current time

* Write markdown file for HTTP requests using curl

---------

Co-authored-by: pmoharana-cmd <pmoharana032474@gmail.com>
2024-11-05 19:12:03 -05:00
Emma Foster
99e43c7b30
Resource Service and Tests (#41)
* Implement Resource service, tests, and test data with 97% coverage.

* Update slug service to return empty list and corresponding tests

* Update resource update tests to grab resource by id from the db to check
2024-11-04 15:10:58 -05:00
Nicolas Asanov
2e0dd3b987
Refactored Table Components (#43)
* Created mock/test table and resource page to see if implementation works

* Fixed typing for TagsInput

* cleaned up imports

* Started moving data manipulation into Table

* moved data manipulation logic into Table

* added useTagsHandler custom hook to consolidate getTagColor and presetOptions state into one function

* Fixed type errors for RowOpenAction

* Refactored ServiceIndex

* Refactored user table

* Updated imports and client facing routes

* added documentation for table components

* Added documentation for TagsHandler

* small changes for cleaner code

* refactored typing for tables. More work needs to be done to ensure tables are overall working properly

* added todo

* updated client paths with new table props

* alterned handleRowUpdate to only use setData

* diverted responsibility of handleRowChange to Drawer instead of Table to remove repetition

* updated documentation

* added sorting util function to Table.tsx to reduce repetition

* Edited sorting func to be more comaptible and edited hideData to be more concise

* formatting

* updated imports

* updated tags for all tables

* removed DataPoint dependecy from User, Service, and Resource models as it was unnecesary

* Added inline documentation to table components

* added documentation for DataPoint model

* Update package-lock.json
2024-11-04 15:10:13 -05:00
39 changed files with 1380 additions and 1353 deletions

View File

@ -1,26 +1,56 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from ..services import ResourceService, UserService
from ..models.resource_model import Resource from backend.models.user_model import User
from ..services import ResourceService, UserService
from typing import List from ..models.resource_model import Resource
api = APIRouter(prefix="/api/resource") from typing import List
openapi_tags = { api = APIRouter(prefix="/api/resource")
"name": "Resource",
"description": "Resource search and related operations.", 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 # TODO: Add security using HTTP Bearer Tokens
@api.get("", response_model=List[Resource], tags=["Resource"]) # TODO: Enable authorization by passing user uuid to API
def get_all( # TODO: Create custom exceptions
user_id: str, @api.post("", response_model=Resource, tags=["Resource"])
resource_svc: ResourceService = Depends(), def create(
user_svc: UserService = Depends(), uuid: str, resource: Resource, user_svc: UserService = Depends(), resource_svc: ResourceService = Depends()
): ):
subject = user_svc.get_user_by_uuid(user_id) subject = user_svc.get_user_by_uuid(uuid)
return resource_svc.create(subject, resource)
return resource_svc.get_resource_by_user(subject)
@api.get("", response_model=List[Resource], tags=["Resource"])
def get_all(
uuid: str, user_svc: UserService = Depends(), resource_svc: ResourceService = Depends()
):
subject = user_svc.get_user_by_uuid(uuid)
return resource_svc.get_resource_by_user(subject)
@api.get("/{name}", response_model=Resource, tags=["Resource"])
def get_by_name(
name:str, uuid:str, user_svc: UserService = Depends(), resource_svc: ResourceService = Depends()
):
subject = user_svc.get_user_by_uuid(uuid)
return resource_svc.get_resource_by_name(name, subject)
@api.put("", response_model=Resource, tags=["Resource"])
def update(
uuid: str, resource: Resource, user_svc: UserService = Depends(), resource_svc: ResourceService = Depends()
):
subject = user_svc.get_user_by_uuid(uuid)
return resource_svc.update(subject, resource)
@api.delete("", response_model=None, tags=["Resource"])
def delete(
uuid: str, resource: Resource, user_svc: UserService = Depends(), resource_svc: ResourceService = Depends()
):
subject = user_svc.get_user_by_uuid(uuid)
resource_svc.delete(subject, resource)

View File

@ -1,26 +1,54 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from ..services import ServiceService, UserService
from ..models.service_model import Service from backend.models.user_model import User
from ..services import ServiceService, UserService
from typing import List from ..models.service_model import Service
api = APIRouter(prefix="/api/service") from typing import List
openapi_tags = { api = APIRouter(prefix="/api/service")
"name": "Service",
"description": "Service search and related operations.", openapi_tags = {
} "name": "Service",
"description": "Service search and related operations.",
}
# TODO: Add security using HTTP Bearer Tokens
# TODO: Enable authorization by passing user uuid to API
# TODO: Create custom exceptions # TODO: Add security using HTTP Bearer Tokens
@api.get("", response_model=List[Service], tags=["Service"]) # TODO: Enable authorization by passing user uuid to API
def get_all( # TODO: Create custom exceptions
user_id: str, @api.post("", response_model=Service, tags=["Service"])
service_svc: ServiceService = Depends(), def create(
user_svc: UserService = Depends(), uuid: str, service: Service, user_svc: UserService = Depends(), service_svc: ServiceService = Depends()
): ):
subject = user_svc.get_user_by_uuid(user_id) subject = user_svc.get_user_by_uuid(uuid)
return service_svc.create(subject, service)
return service_svc.get_service_by_user(subject)
@api.get("", response_model=List[Service], tags=["Service"])
def get_all(
uuid: str, user_svc: UserService = Depends(), service_svc: ServiceService = Depends()
):
subject = user_svc.get_user_by_uuid(uuid)
return service_svc.get_service_by_user(subject)
@api.get("/{name}", response_model=Service, tags=["Service"])
def get_by_name(
name: str, uuid: str, user_svc: UserService = Depends(), service_svc: ServiceService = Depends()
):
subject = user_svc.get_user_by_uuid(uuid)
return service_svc.get_service_by_name(name, subject)
@api.put("", response_model=Service, tags=["Service"])
def update(
uuid: str, service: Service, user_svc: UserService = Depends(), service_svc: ServiceService = Depends()
):
subject = user_svc.get_user_by_uuid(uuid)
return service_svc.update(subject, service)
@api.delete("", response_model=None, tags=["Service"])
def delete(
uuid: str, service: Service, user_svc: UserService = Depends(), service_svc: ServiceService = Depends()
):
subject = user_svc.get_user_by_uuid(uuid)
service_svc.delete(subject, service)

51
backend/api/tag.py Normal file
View File

@ -0,0 +1,51 @@
from fastapi import APIRouter, Depends
from backend.models.tag_model import Tag
from backend.models.user_model import User
from backend.services.tag import TagService
from ..services import ResourceService, UserService
from ..models.resource_model import Resource
from typing import List
api = APIRouter(prefix="/api/tag")
openapi_tags = {
"name": "Tag",
"description": "Tag CRUD operations.",
}
# TODO: Add security using HTTP Bearer Tokens
# TODO: Enable authorization by passing user uuid to API
# TODO: Create custom exceptions
@api.post("", response_model=Tag, tags=["Tag"])
def create(
subject: User,
tag: Tag,
tag_service: TagService=Depends()
):
return tag_service.create(subject, tag)
@api.get("", response_model=List[Tag], tags=["Tag"])
def get_all(
subject: User,
tag_svc: TagService=Depends()
):
return tag_svc.get_all()
@api.put("", response_model=Tag, tags=["Tag"])
def update(
subject: User,
tag: Tag,
tag_svc: TagService=Depends()
):
return tag_svc.delete(subject, tag)
@api.delete("", response_model=None, tags=["Tag"])
def delete(
subject: User,
tag: Tag,
tag_svc: TagService=Depends()
):
tag_svc.delete(subject, tag)

147
backend/api/test_routes.md Normal file
View File

@ -0,0 +1,147 @@
# Synopsis
Collection of sample curl requests for api routes.
# Resources
## Get All
Given an admin UUID, gets all of the resources from ResourceEntity.
```
curl -X 'GET' \
'http://127.0.0.1:8000/api/resource?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \
-H 'accept: application/json'
```
## Get by Name
Given the name of a resource and an admin UUID, gets a resource from ResourceEntity by name.
```
curl -X 'GET' \
'http://127.0.0.1:8000/api/resource/Financial%20Empowerment%20Center?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \
-H 'accept: application/json'
```
## Create
Given an admin UUID and a new resource object, adds a resource to ResourceEntity.
```
curl -X 'POST' \
'http://127.0.0.1:8000/api/resource?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"id": 25,
"name": "algorithms and analysis textbook",
"summary": "textbook written by kevin sun for c550",
"link": "kevinsun.org",
"program": "DOMESTIC",
"created_at": "2024-11-04T20:07:31.875166"
}'
```
## Update
Given an admin UUID and a modified resource object, updates the resource with a matching ID if it exists.
```
curl -X 'PUT' \
'http://127.0.0.1:8000/api/resource?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"id": 25,
"name": "algorithms and analysis textbook",
"summary": "textbook written by the goat himself, kevin sun, for c550",
"link": "kevinsun.org",
"program": "DOMESTIC",
"created_at": "2024-11-04T20:07:31.875166"
}'
```
## Delete
Given an admin UUID and a resource object, deletes the resource with a matching ID if it exists.
```
curl -X 'DELETE' \
'http://127.0.0.1:8000/api/resource?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"id": 25,
"name": "algorithms and analysis textbook",
"summary": "textbook written by the goat himself, kevin sun, for c550",
"link": "kevinsun.org",
"program": "DOMESTIC",
"created_at": "2024-11-04T20:07:31.875166"
}'
```
# Services
## Get All
Given an admin UUID, gets all of the services from ServiceEntity.
```
curl -X 'GET' \
'http://127.0.0.1:8000/api/service?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \
-H 'accept: application/json'
```
## Get by Name
Given the name of a service and an admin UUID, gets a service from ServiceEntity by name.
```
curl -X 'GET' \
'http://127.0.0.1:8000/api/service/Shelter%20Placement?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \
-H 'accept: application/json'
```
## Create
Given an admin UUID and a new service object, adds a service to ServiceEntity.
```
curl -X 'POST' \
'http://127.0.0.1:8000/api/service?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"id": 25,
"created_at": "2024-11-04T20:07:31.890412",
"name": "c550 tutoring",
"status": "open",
"summary": "tutoring for kevin sun'\''s c550 class",
"requirements": [
"must be in c550"
],
"program": "COMMUNITY"
}'
```
## Update
Given an admin UUID and a modified service object, updates the service with a matching ID if it exists.
```
curl -X 'PUT' \
'http://127.0.0.1:8000/api/service?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"id": 25,
"created_at": "2024-11-04T20:07:31.890412",
"name": "c550 tutoring",
"status": "closed",
"summary": "tutoring for kevin sun'\''s c550 class",
"requirements": [
"must be in c550"
],
"program": "COMMUNITY"
}'
```
## Delete
Given an admin UUID and a service object, deletes the service with a matching ID if it exists.
```
curl -X 'DELETE' \
'http://127.0.0.1:8000/api/service?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"id": 25,
"created_at": "2024-11-04T20:07:31.890412",
"name": "c550 tutoring",
"status": "closed",
"summary": "tutoring for kevin sun'\''s c550 class",
"requirements": [
"must be in c550"
],
"program": "COMMUNITY"
}'
```

View File

@ -1,67 +1,67 @@
""" Defines the table for storing resources """ """ Defines the table for storing resources """
# Import our mapped SQL types from SQLAlchemy # Import our mapped SQL types from SQLAlchemy
from sqlalchemy import Integer, String, DateTime, Enum from sqlalchemy import Integer, String, DateTime, Enum
# Import mapping capabilities from the SQLAlchemy ORM # Import mapping capabilities from the SQLAlchemy ORM
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
# Import the EntityBase that we are extending # Import the EntityBase that we are extending
from .entity_base import EntityBase from .entity_base import EntityBase
# Import datetime for created_at type # Import datetime for created_at type
from datetime import datetime from datetime import datetime
# Import self for to model # Import self for to model
from typing import Self from typing import Self
from backend.entities.program_enum import Program_Enum from backend.entities.program_enum import Program_Enum
from ..models.resource_model import Resource from ..models.resource_model import Resource
class ResourceEntity(EntityBase): class ResourceEntity(EntityBase):
# set table name # set table name
__tablename__ = "resource" __tablename__ = "resource"
# set fields # set fields
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
name: Mapped[str] = mapped_column(String(64), nullable=False) name: Mapped[str] = mapped_column(String(64), nullable=False)
summary: Mapped[str] = mapped_column(String(100), nullable=False) summary: Mapped[str] = mapped_column(String(100), nullable=False)
link: Mapped[str] = mapped_column(String, nullable=False) link: Mapped[str] = mapped_column(String, nullable=False)
program: Mapped[Program_Enum] = mapped_column(Enum(Program_Enum), nullable=False) program: Mapped[Program_Enum] = mapped_column(Enum(Program_Enum), nullable=False)
# relationships # relationships
resourceTags: Mapped[list["ResourceTagEntity"]] = relationship( resourceTags: Mapped[list["ResourceTagEntity"]] = relationship(
back_populates="resource", cascade="all,delete" back_populates="resource", cascade="all,delete"
) )
@classmethod @classmethod
def from_model(cls, model: Resource) -> Self: def from_model(cls, model: Resource) -> Self:
""" """
Create a UserEntity from a User model. Create a UserEntity from a User model.
Args: Args:
model (User): The model to create the entity from. model (User): The model to create the entity from.
Returns: Returns:
Self: The entity (not yet persisted). Self: The entity (not yet persisted).
""" """
return cls( return cls(
id=model.id, id=model.id,
created_at=model.created_at, created_at=model.created_at,
name=model.name, name=model.name,
summary=model.summary, summary=model.summary,
link=model.link, link=model.link,
program=model.program, program=model.program,
) )
def to_model(self) -> Resource: def to_model(self) -> Resource:
return Resource( return Resource(
id=self.id, id=self.id,
created_at=self.created_at, created_at=self.created_at,
name=self.name, name=self.name,
summary=self.summary, summary=self.summary,
link=self.link, link=self.link,
program=self.program, program=self.program,
) )

View File

@ -1,46 +1,46 @@
""" Defines the table for resource tags """ """ Defines the table for resource tags """
# Import our mapped SQL types from SQLAlchemy # Import our mapped SQL types from SQLAlchemy
from sqlalchemy import ForeignKey, Integer, String, DateTime from sqlalchemy import ForeignKey, Integer, String, DateTime
# Import mapping capabilities from the SQLAlchemy ORM # Import mapping capabilities from the SQLAlchemy ORM
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
# Import the EntityBase that we are extending # Import the EntityBase that we are extending
from .entity_base import EntityBase from .entity_base import EntityBase
# Import datetime for created_at type # Import datetime for created_at type
from datetime import datetime from datetime import datetime
# Import self for to model # Import self for to model
from typing import Self from typing import Self
class ResourceTagEntity(EntityBase): class ResourceTagEntity(EntityBase):
# set table name to user in the database # set table name to user in the database
__tablename__ = "resource_tag" __tablename__ = "resource_tag"
# set fields or 'columns' for the user table # set fields or 'columns' for the user table
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
resourceId: Mapped[int] = mapped_column(ForeignKey("resource.id")) resourceId: Mapped[int] = mapped_column(ForeignKey("resource.id"))
tagId: Mapped[int] = mapped_column(ForeignKey("tag.id")) tagId: Mapped[int] = mapped_column(ForeignKey("tag.id"))
# relationships # relationships
resource: Mapped["ResourceEntity"] = relationship(back_populates="resourceTags") resource: Mapped["ResourceEntity"] = relationship(back_populates="resourceTags")
tag: Mapped["TagEntity"] = relationship(back_populates="resourceTags") tag: Mapped["TagEntity"] = relationship(back_populates="resourceTags")
# @classmethod # @classmethod
# def from_model (cls, model: resource_tag_model) -> Self: # def from_model (cls, model: resource_tag_model) -> Self:
# return cls ( # return cls (
# id = model.id, # id = model.id,
# resourceId = model.resourceId, # resourceId = model.resourceId,
# tagId = model.tagId, # tagId = model.tagId,
# ) # )
# def to_model (self) -> resource_tag_model: # def to_model (self) -> resource_tag_model:
# return user_model( # return user_model(
# id = self.id, # id = self.id,
# resourceId = self.resourceId, # resourceId = self.resourceId,
# tagId = self.tagId, # tagId = self.tagId,
# ) # )

View File

@ -1,47 +1,47 @@
""" Defines the table for storing services """ """ Defines the table for storing services """
# Import our mapped SQL types from SQLAlchemy # Import our mapped SQL types from SQLAlchemy
from sqlalchemy import Integer, String, DateTime, ARRAY from sqlalchemy import Integer, String, DateTime, ARRAY
# Import mapping capabilities from the SQLAlchemy ORM # Import mapping capabilities from the SQLAlchemy ORM
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
# Import the EntityBase that we are extending # Import the EntityBase that we are extending
from .entity_base import EntityBase from .entity_base import EntityBase
# Import datetime for created_at type # Import datetime for created_at type
from datetime import datetime from datetime import datetime
# Import enums for Program # Import enums for Program
import enum import enum
from sqlalchemy import Enum from sqlalchemy import Enum
from backend.models.service_model import Service from backend.models.service_model import Service
from typing import Self from typing import Self
from backend.models.enum_for_models import ProgramTypeEnum from backend.models.enum_for_models import ProgramTypeEnum
class ServiceEntity(EntityBase): class ServiceEntity(EntityBase):
# set table name # set table name
__tablename__ = "service" __tablename__ = "service"
# set fields # set fields
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
name: Mapped[str] = mapped_column(String(32), nullable=False) name: Mapped[str] = mapped_column(String(32), nullable=False)
status: Mapped[str] = mapped_column(String(32), nullable=False) status: Mapped[str] = mapped_column(String(32), nullable=False)
summary: Mapped[str] = mapped_column(String(100), nullable=False) summary: Mapped[str] = mapped_column(String(100), nullable=False)
requirements: Mapped[list[str]] = mapped_column(ARRAY(String)) requirements: Mapped[list[str]] = mapped_column(ARRAY(String))
program: Mapped[ProgramTypeEnum] = mapped_column(Enum(ProgramTypeEnum), nullable=False) program: Mapped[ProgramTypeEnum] = mapped_column(Enum(ProgramTypeEnum), nullable=False)
# relationships # relationships
serviceTags: Mapped[list["ServiceTagEntity"]] = relationship( serviceTags: Mapped[list["ServiceTagEntity"]] = relationship(
back_populates="service", cascade="all,delete" back_populates="service", cascade="all,delete"
) )
def to_model(self) -> Service: def to_model(self) -> Service:
return Service(id=self.id, name=self.name, status=self.status, summary=self.summary, requirements=self.requirements, program=self.program) return Service(id=self.id, name=self.name, status=self.status, summary=self.summary, requirements=self.requirements, program=self.program)
@classmethod @classmethod
def from_model(cls, model:Service) -> Self: def from_model(cls, model:Service) -> Self:
return cls(id=model.id, name=model.name, status=model.status, summary=model.summary, requirements=model.requirements, program=model.program) return cls(id=model.id, name=model.name, status=model.status, summary=model.summary, requirements=model.requirements, program=model.program)

View File

@ -1,25 +1,26 @@
""" Defines the table for service tags """ """ Defines the table for service tags """
# Import our mapped SQL types from SQLAlchemy # Import our mapped SQL types from SQLAlchemy
from sqlalchemy import ForeignKey, Integer from sqlalchemy import ForeignKey, Integer
# Import mapping capabilities from the SQLAlchemy ORM # Import mapping capabilities from the SQLAlchemy ORM
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
# Import the EntityBase that we are extending # Import the EntityBase that we are extending
from .entity_base import EntityBase from .entity_base import EntityBase
class ServiceTagEntity(EntityBase): class ServiceTagEntity(EntityBase):
# set table name to user in the database # set table name to user in the database
__tablename__ = "service_tag" __tablename__ = "service_tag"
# set fields or 'columns' for the user table # set fields or 'columns' for the user table
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
serviceId: Mapped[int] = mapped_column(ForeignKey("service.id")) serviceId: Mapped[int] = mapped_column(ForeignKey("service.id"))
tagId: Mapped[int] = mapped_column(ForeignKey("tag.id")) tagId: Mapped[int] = mapped_column(ForeignKey("tag.id"))
# relationships # relationships
service: Mapped["ServiceEntity"] = relationship(back_populates="serviceTags") service: Mapped["ServiceEntity"] = relationship(back_populates="serviceTags")
tag: Mapped["TagEntity"] = relationship(back_populates="serviceTags") tag: Mapped["TagEntity"] = relationship(back_populates="serviceTags")

View File

@ -1,65 +1,64 @@
""" Defines the table for storing tags """ """ Defines the table for storing tags """
# Import our mapped SQL types from SQLAlchemy # Import our mapped SQL types from SQLAlchemy
from sqlalchemy import Integer, String, DateTime from sqlalchemy import Integer, String, DateTime
# Import mapping capabilities from the SQLAlchemy ORM # Import mapping capabilities from the SQLAlchemy ORM
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
# Import the EntityBase that we are extending # Import the EntityBase that we are extending
from .entity_base import EntityBase from .entity_base import EntityBase
# Import datetime for created_at type # Import datetime for created_at type
from datetime import datetime from datetime import datetime
from ..models.tag_model import Tag from ..models.tag_model import Tag
from typing import Self from typing import Self
class TagEntity(EntityBase): class TagEntity(EntityBase):
#set table name #set table name
__tablename__ = "tag" __tablename__ = "tag"
#set fields #set fields
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
content: Mapped[str] = mapped_column(String(100), nullable=False) content: Mapped[str] = mapped_column(String(100), nullable=False)
#relationships #relationships
resourceTags: Mapped[list["ResourceTagEntity"]] = relationship(back_populates="tag", cascade="all,delete") resourceTags: Mapped[list["ResourceTagEntity"]] = relationship(back_populates="tag", cascade="all,delete")
serviceTags: Mapped[list["ServiceTagEntity"]] = relationship(back_populates="tag", cascade="all,delete") serviceTags: Mapped[list["ServiceTagEntity"]] = relationship(back_populates="tag", cascade="all,delete")
@classmethod @classmethod
def from_model(cls, model: Tag) -> Self: def from_model(cls, model: Tag) -> Self:
""" """
Create a user entity from model Create a user entity from model
Args: model (User): the model to create the entity from Args: model (User): the model to create the entity from
Returns: Returns:
self: The entity self: The entity
""" """
return cls( return cls(
id=model.id, id=model.id,
content=model.id, created_at=model.created_at,
) content=model.id,
)
def to_model(self) -> Tag:
""" def to_model(self) -> Tag:
Create a user model from entity """
Create a user model from entity
Returns:
User: A User model for API usage Returns:
""" User: A User model for API usage
"""
return Tag(
id=self.id, return Tag(
content=self.content, id=self.id,
) create_at=self.created_at,
content=self.content,
)

View File

@ -1,34 +1,36 @@
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from .api import user, health, service, resource
from .api import user, health, service, resource, tag
description = """
Welcome to the **COMPASS** RESTful Application Programming Interface. description = """
""" Welcome to the **COMPASS** RESTful Application Programming Interface.
"""
app = FastAPI(
title="Compass API", app = FastAPI(
version="0.0.1", title="Compass API",
description=description, version="0.0.1",
openapi_tags=[ description=description,
user.openapi_tags, openapi_tags=[
health.openapi_tags, user.openapi_tags,
service.openapi_tags, health.openapi_tags,
resource.openapi_tags, service.openapi_tags,
], resource.openapi_tags,
) tag.openapi_tags
],
app.add_middleware(GZipMiddleware) )
feature_apis = [user, health, service, resource] app.add_middleware(GZipMiddleware)
for feature_api in feature_apis: feature_apis = [user, health, service, resource, tag]
app.include_router(feature_api.api)
for feature_api in feature_apis:
app.include_router(feature_api.api)
# Add application-wide exception handling middleware for commonly encountered API Exceptions
@app.exception_handler(Exception)
def permission_exception_handler(request: Request, e: Exception): # Add application-wide exception handling middleware for commonly encountered API Exceptions
return JSONResponse(status_code=403, content={"message": str(e)}) @app.exception_handler(Exception)
def permission_exception_handler(request: Request, e: Exception):
return JSONResponse(status_code=403, content={"message": str(e)})

View File

@ -12,4 +12,4 @@ class Resource(BaseModel):
summary: str = Field(..., max_length=300, description="The summary 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") link: str = Field(..., max_length=150, description="link to the resource")
program: ProgramTypeEnum program: ProgramTypeEnum
created_at: Optional[datetime] created_at: Optional[datetime] = datetime.now()

View File

@ -8,7 +8,7 @@ from .enum_for_models import ProgramTypeEnum
class Service(BaseModel): class Service(BaseModel):
id: int | None = None id: int | None = None
created_at: datetime | None = None created_at: datetime | None = datetime.now()
name: str name: str
status: str status: str
summary: str summary: str

View File

@ -10,4 +10,4 @@ class Tag(BaseModel):
content: str = Field( content: str = Field(
..., max_length=600, description="content associated with the tag" ..., max_length=600, description="content associated with the tag"
) )
created_at: datetime | None = None created_at: datetime | None = datetime.now()

View File

@ -14,5 +14,5 @@ class User(BaseModel):
group: str group: str
program: List[ProgramTypeEnum] program: List[ProgramTypeEnum]
role: UserTypeEnum role: UserTypeEnum
created_at: Optional[datetime] created_at: Optional[datetime] = datetime.now()
uuid: str | None = None uuid: str | None = None

View File

@ -20,6 +20,8 @@ class UserPermissionException(Exception):
class ServiceNotFoundException(Exception): class ServiceNotFoundException(Exception):
"""Exception for when the service being requested is not in the table.""" """Exception for when the service being requested is not in the table."""
class TagNotFoundException(Exception):
"""Exception for when the tag being requested is not in the table."""
class ProgramNotAssignedException(Exception): class ProgramNotAssignedException(Exception):
"""Exception for when the user does not have correct access for requested services.""" """Exception for when the user does not have correct access for requested services."""

View File

@ -1,12 +1,12 @@
from fastapi import Depends from fastapi import Depends
from ..database import db_session from ..database import db_session
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import select from sqlalchemy import and_, select
from ..models.resource_model import Resource from ..models.resource_model import Resource
from ..entities.resource_entity import ResourceEntity from ..entities.resource_entity import ResourceEntity
from ..models.user_model import User, UserTypeEnum from ..models.user_model import User, UserTypeEnum
from .exceptions import ResourceNotFoundException from .exceptions import ProgramNotAssignedException, ResourceNotFoundException
class ResourceService: class ResourceService:
@ -14,152 +14,125 @@ class ResourceService:
def __init__(self, session: Session = Depends(db_session)): def __init__(self, session: Session = Depends(db_session)):
self._session = session self._session = session
def get_resource_by_user(self, subject: User): def get_resource_by_user(self, subject: User) -> list[Resource]:
"""Resource method getting all of the resources that a user has access to based on role""" """Resource method getting all of the resources that a user has access to based on role"""
if subject.role != UserTypeEnum.VOLUNTEER: if subject.role != UserTypeEnum.VOLUNTEER:
query = select(ResourceEntity) query = select(ResourceEntity)
entities = self._session.scalars(query).all() entities = self._session.scalars(query).all()
return [resource.to_model() for resource in entities] return [resource.to_model() for resource in entities]
else: else:
programs = subject.program programs = subject.program
resources = [] resources = []
for program in programs: for program in programs:
query = select(ResourceEntity).filter(ResourceEntity.program == program) entities = (
entities = self._session.scalars(query).all() self._session.query(ResourceEntity)
.where(ResourceEntity.program == program)
.all()
)
for entity in entities: for entity in entities:
resources.append(entity) resources.append(entity.to_model())
return [resource for resource in resources]
return [resource.to_model() for resource in resources] def get_resource_by_name(self, name: str, subject: User) -> Resource:
"""Get a resource by name."""
query = select(ResourceEntity).where(
and_(
ResourceEntity.name == name, ResourceEntity.program.in_(subject.program)
)
)
entity = self._session.scalars(query).one_or_none()
if entity is None:
raise ResourceNotFoundException(
f"Resource with name: {name} does not exist or program has not been assigned."
)
return entity.to_model()
def create(self, user: User, resource: Resource) -> Resource: def create(self, subject: User, resource: Resource) -> Resource:
""" """
Creates a resource based on the input object and adds it to the table if the user has the right permissions. Creates a resource based on the input object and adds it to the table if the user has the right permissions.
Parameters: Parameters:
user: a valid User model representing the currently logged in User user: a valid User model representing the currently logged in User
resource: Resource object to add to table resource: Resource object to add to table
Returns: Returns:
Resource: Object added to table Resource: Object added to table
""" """
if resource.role != user.role or resource.group != user.group: if subject.role != UserTypeEnum.ADMIN:
raise PermissionError( raise ProgramNotAssignedException(
"User does not have permission to add resources in this role or group." f"User is not {UserTypeEnum.ADMIN}, cannot update service"
) )
resource_entity = ResourceEntity.from_model(resource) resource_entity = ResourceEntity.from_model(resource)
self._session.add(resource_entity) self._session.add(resource_entity)
self._session.commit() self._session.commit()
return resource_entity.to_model() return resource_entity.to_model()
def get_by_id(self, user: User, id: int) -> Resource: def update(self, subject: User, resource: Resource) -> Resource:
"""
Gets a resource based on the resource id that the user has access to
Parameters:
user: a valid User model representing the currently logged in User
id: int, the id of the resource
Returns:
Resource
Raises:
ResourceNotFoundException: If no resource is found with id
"""
resource = (
self._session.query(ResourceEntity)
.filter(
ResourceEntity.id == id,
ResourceEntity.role == user.role,
ResourceEntity.group == user.group,
)
.one_or_none()
)
if resource is None:
raise ResourceNotFoundException(f"No resource found with id: {id}")
return resource.to_model()
def update(self, user: User, resource: ResourceEntity) -> Resource:
""" """
Update the resource if the user has access Update the resource if the user has access
Parameters: Parameters:
user: a valid User model representing the currently logged in User user: a valid User model representing the currently logged in User
resource (ResourceEntity): Resource to update resource (ResourceEntity): Resource to update
Returns: Returns:
Resource: Updated resource object Resource: Updated resource object
Raises: Raises:
ResourceNotFoundException: If no resource is found with the corresponding ID ResourceNotFoundException: If no resource is found with the corresponding ID
""" """
if resource.role != user.role or resource.group != user.group: if subject.role != UserTypeEnum.ADMIN:
raise PermissionError( raise ProgramNotAssignedException(
"User does not have permission to update this resource." f"User is not {UserTypeEnum.ADMIN}, cannot update service"
) )
query = select(ResourceEntity).where(ResourceEntity.id == resource.id)
obj = self._session.get(ResourceEntity, resource.id) if resource.id else None entity = self._session.scalars(query).one_or_none()
if entity is None:
if obj is None:
raise ResourceNotFoundException( raise ResourceNotFoundException(
f"No resource found with matching id: {resource.id}" f"No resource found with matching id: {resource.id}"
) )
entity.name = resource.name
obj.update_from_model(resource) # Assuming an update method exists entity.summary = resource.summary
entity.link = resource.link
entity.program = resource.program
self._session.commit() self._session.commit()
return entity.to_model()
return obj.to_model() def delete(self, subject: User, resource: Resource) -> None:
def delete(self, user: User, id: int) -> None:
""" """
Delete resource based on id that the user has access to Delete resource based on id that the user has access to
Parameters: Parameters:
user: a valid User model representing the currently logged in User user: a valid User model representing the currently logged in User
id: int, a unique resource id id: int, a unique resource id
Raises: Raises:
ResourceNotFoundException: If no resource is found with the corresponding id ResourceNotFoundException: If no resource is found with the corresponding id
""" """
resource = ( if subject.role != UserTypeEnum.ADMIN:
self._session.query(ResourceEntity) raise ProgramNotAssignedException(
.filter( f"User is not {UserTypeEnum.ADMIN}, cannot update service"
ResourceEntity.id == id,
ResourceEntity.role == user.role,
ResourceEntity.group == user.group,
) )
.one_or_none() query = select(ResourceEntity).where(ResourceEntity.id == resource.id)
) entity = self._session.scalars(query).one_or_none()
if entity is None:
if resource is None: raise ResourceNotFoundException(
raise ResourceNotFoundException(f"No resource found with matching id: {id}") f"No resource found with matching id: {resource.id}"
)
self._session.delete(resource) self._session.delete(entity)
self._session.commit() self._session.commit()
def get_by_slug(self, user: User, search_string: str) -> list[Resource]: def get_by_slug(self, user: User, search_string: str) -> list[Resource]:
""" """
Get a list of resources given a search string that the user has access to Get a list of resources given a search string that the user has access to
Parameters: Parameters:
user: a valid User model representing the currently logged in User user: a valid User model representing the currently logged in User
search_string: a string to search resources by search_string: a string to search resources by
Returns: Returns:
list[Resource]: list of resources relating to the string list[Resource]: list of resources relating to the string
Raises: Raises:
ResourceNotFoundException if no resource is found with the corresponding slug ResourceNotFoundException if no resource is found with the corresponding slug
""" """
query = select(ResourceEntity).where( query = select(ResourceEntity).where(
ResourceEntity.title.ilike(f"%{search_string}%"), ResourceEntity.name.ilike(f"%{search_string}%"),
ResourceEntity.role == user.role, ResourceEntity.program.in_(user.program),
ResourceEntity.group == user.group,
) )
entities = self._session.scalars(query).all() entities = self._session.scalars(query).all()
if not entities:
return []
return [entity.to_model() for entity in entities] return [entity.to_model() for entity in entities]

View File

@ -1,127 +1,101 @@
from fastapi import Depends from fastapi import Depends
from ..database import db_session from ..database import db_session
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func, select, and_, func, or_, exists, or_ from sqlalchemy import func, select, and_, func, or_, exists, or_
from backend.models.service_model import Service from backend.models.service_model import Service
from backend.models.user_model import User from backend.models.user_model import User
from backend.entities.service_entity import ServiceEntity from backend.entities.service_entity import ServiceEntity
from backend.models.enum_for_models import ProgramTypeEnum, UserTypeEnum from backend.models.enum_for_models import ProgramTypeEnum, UserTypeEnum
from backend.services.exceptions import ( from backend.services.exceptions import (
ServiceNotFoundException, ServiceNotFoundException,
ProgramNotAssignedException, ProgramNotAssignedException,
) )
class ServiceService: class ServiceService:
def __init__(self, session: Session = Depends(db_session)): def __init__(self, session: Session = Depends(db_session)):
self._session = session self._session = session
def get_service_by_program(self, program: ProgramTypeEnum) -> list[Service]: def get_service_by_user(self, subject: User) -> list[Service]:
"""Service method getting services belonging to a particular program.""" """Resource method getting all of the resources that a user has access to based on role"""
query = select(ServiceEntity).filter(ServiceEntity.program == program) if subject.role != UserTypeEnum.VOLUNTEER:
entities = self._session.scalars(query) query = select(ServiceEntity)
entities = self._session.scalars(query).all()
return [entity.to_model() for entity in entities] return [service.to_model() for service in entities]
else:
def get_service_by_id(self, id: int) -> Service: programs = subject.program
"""Service method getting services by id.""" services = []
query = select(ServiceEntity).filter(ServiceEntity.id == id) for program in programs:
entity = self._session.scalars(query).one_or_none() entities = self._session.query(ServiceEntity).where(ServiceEntity.program == program).all()
for entity in entities:
if entity is None: services.append(entity.to_model())
raise ServiceNotFoundException(f"Service with id: {id} does not exist") return [service for service in services]
return entity.to_model() def get_service_by_name(self, name: str, subject: User) -> Service:
"""Service method getting services by id."""
def get_service_by_name(self, name: str) -> Service: query = select(ServiceEntity).where(
"""Service method getting services by id.""" and_(
query = select(ServiceEntity).filter(ServiceEntity.name == name) ServiceEntity.name == name, ServiceEntity.program.in_(subject.program)
entity = self._session.scalars(query).one_or_none() )
)
if entity is None: entity = self._session.scalars(query).one_or_none()
raise ServiceNotFoundException(f"Service with name: {name} does not exist")
if entity is None:
return entity.to_model() raise ServiceNotFoundException(f"Service with name: {name} does not exist or program has not been assigned")
def get_service_by_user(self, subject: User): return entity.to_model()
"""Service method getting all of the services that a user has access to based on role"""
if subject.role != UserTypeEnum.VOLUNTEER: def create(self, subject: User, service: Service) -> Service:
query = select(ServiceEntity) """Creates/adds a service to the table."""
entities = self._session.scalars(query).all() if subject.role != UserTypeEnum.ADMIN:
raise ProgramNotAssignedException(
return [service.to_model() for service in entities] f"User is not {UserTypeEnum.ADMIN}, cannot create service"
else: )
programs = subject.program
services = [] service_entity = ServiceEntity.from_model(service)
for program in programs: self._session.add(service_entity)
query = select(ServiceEntity).filter(ServiceEntity.program == program) self._session.commit()
entities = self._session.scalars(query).all() return service_entity.to_model()
for entity in entities:
services.append(entity) def update(self, subject: User, service: Service) -> Service:
"""Updates a service if in the table."""
return [service.to_model() for service in services] if subject.role != UserTypeEnum.ADMIN:
raise ProgramNotAssignedException(
def get_all(self, subject: User) -> list[Service]: f"User is not {UserTypeEnum.ADMIN}, cannot update service"
"""Service method retrieving all of the services in the table.""" )
if subject.role == UserTypeEnum.VOLUNTEER:
raise ProgramNotAssignedException( query = select(ServiceEntity).where(ServiceEntity.id == service.id)
f"User is not {UserTypeEnum.ADMIN} or {UserTypeEnum.VOLUNTEER}, cannot get all" entity = self._session.scalars(query).one_or_none()
)
if entity is None:
query = select(ServiceEntity) raise ServiceNotFoundException(
entities = self._session.scalars(query).all() "The service you are searching for does not exist."
)
return [service.to_model() for service in entities]
entity.name = service.name
def create(self, subject: User, service: Service) -> Service: entity.status = service.status
"""Creates/adds a service to the table.""" entity.summary = service.summary
if subject.role != UserTypeEnum.ADMIN: entity.requirements = service.requirements
raise ProgramNotAssignedException( entity.program = service.program
f"User is not {UserTypeEnum.ADMIN}, cannot create service" self._session.commit()
)
return entity.to_model()
service_entity = ServiceEntity.from_model(service)
self._session.add(service_entity) def delete(self, subject: User, service: Service) -> None:
self._session.commit() """Deletes a service from the table."""
return service_entity.to_model() if subject.role != UserTypeEnum.ADMIN:
raise ProgramNotAssignedException(f"User is not {UserTypeEnum.ADMIN}")
def update(self, subject: User, service: Service) -> Service:
"""Updates a service if in the table.""" query = select(ServiceEntity).where(ServiceEntity.id == service.id)
if subject.role != UserTypeEnum.ADMIN: entity = self._session.scalars(query).one_or_none()
raise ProgramNotAssignedException(
f"User is not {UserTypeEnum.ADMIN}, cannot update service" if entity is None:
) raise ServiceNotFoundException(
"The service you are searching for does not exist."
service_entity = self._session.get(ServiceEntity, service.id) )
if service_entity is None: self._session.delete(entity)
raise ServiceNotFoundException( self._session.commit()
"The service you are searching for does not exist."
)
service_entity.name = service.name
service_entity.status = service.status
service_entity.summary = service.summary
service_entity.requirements = service.requirements
service_entity.program = service.program
self._session.commit()
return service_entity.to_model()
def delete(self, subject: User, service: Service) -> None:
"""Deletes a service from the table."""
if subject.role != UserTypeEnum.ADMIN:
raise ProgramNotAssignedException(f"User is not {UserTypeEnum.ADMIN}")
service_entity = self._session.get(ServiceEntity, service.id)
if service_entity is None:
raise ServiceNotFoundException(
"The service you are searching for does not exist."
)
self._session.delete(service_entity)
self._session.commit()

View File

@ -1,20 +1,52 @@
from fastapi import Depends from fastapi import Depends
from ..database import db_session
from sqlalchemy.orm import Session from backend.models.enum_for_models import UserTypeEnum
from ..models.tag_model import Tag from backend.models.user_model import User
from ..entities.tag_entity import TagEntity from backend.services.exceptions import TagNotFoundException
from sqlalchemy import select from ..database import db_session
from sqlalchemy.orm import Session
from ..models.tag_model import Tag
class TagService: from ..entities.tag_entity import TagEntity
from sqlalchemy import select
def __init__(self, session: Session = Depends(db_session)):
self._session = session # Add in checks for user permission?
class TagService:
def all(self) -> list[Tag]:
"""Returns a list of all Tags""" def __init__(self, session: Session = Depends(db_session)):
self._session = session
query = select(TagEntity)
entities = self._session.scalars(query).all() def get_all(self) -> list[Tag]:
"""Returns a list of all Tags"""
return [entity.to_model() for entity in entities] query = select(TagEntity)
entities = self._session.scalars(query).all()
return [entity.to_model() for entity in entities]
def create(self, subject: User, tag: Tag) -> Tag:
entity = TagEntity.from_model(tag)
self._session.add(entity)
self._session.commit()
return entity.to_model()
def update(self, subject: User, tag: Tag) -> Tag:
query = select(TagEntity).where(TagEntity.id == tag.id)
entity = self._session.scalars(query).one_or_none()
if entity is None:
raise TagNotFoundException(f"Tag with id {tag.id} does not exist")
entity.content = tag.content
self._session.commit()
return entity.to_model()
def delete(self, subject: User, tag: Tag) -> None:
query = select(TagEntity).where(TagEntity.id == tag.id)
entity = self._session.scalars(query).one_or_none()
if entity is None:
raise TagNotFoundException(f"Tag with id {tag.id} does not exist")
self._session.delete(entity)
self._session.commit()

View File

@ -4,7 +4,7 @@ import pytest
from sqlalchemy import Engine, create_engine, text from sqlalchemy import Engine, create_engine, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
from .services import user_test_data, tag_test_data, service_test_data from .services import user_test_data, tag_test_data, service_test_data, resource_test_data
from ..database import _engine_str from ..database import _engine_str
from ..env import getenv from ..env import getenv
@ -57,5 +57,6 @@ def setup_insert_data_fixture(session: Session):
user_test_data.insert_fake_data(session) user_test_data.insert_fake_data(session)
tag_test_data.insert_fake_data(session) tag_test_data.insert_fake_data(session)
service_test_data.insert_fake_data(session) service_test_data.insert_fake_data(session)
resource_test_data.insert_fake_data(session)
session.commit() session.commit()
yield yield

View File

@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from ...services import UserService from ...services import UserService
from ...services import TagService from ...services import TagService
from ...services import ServiceService from ...services import ServiceService
from ...services import ResourceService
@ -23,4 +24,9 @@ def tag_svc(session: Session):
@pytest.fixture() @pytest.fixture()
def service_svc(session: Session): def service_svc(session: Session):
"""This fixture is used to test the ServiceService class""" """This fixture is used to test the ServiceService class"""
return ServiceService(session) return ServiceService(session)
@pytest.fixture()
def resource_svc(session: Session):
"""This fixture is used to test the ResourceService class"""
return ResourceService(session)

View File

@ -0,0 +1,126 @@
from backend.models.user_model import User
from backend.entities.resource_entity import ResourceEntity
from ...models.enum_for_models import ProgramTypeEnum
from backend.services.resource import ResourceService
from backend.services.tag import TagService
from backend.services.exceptions import ResourceNotFoundException
from . import resource_test_data
from . import user_test_data
from .fixtures import resource_svc, user_svc, tag_svc
from backend.models.resource_model import Resource
import pytest
def test_get_resource_by_user_volunteer(resource_svc: ResourceService):
""" Test getting resources by a volunteer """
resources = resource_svc.get_resource_by_user(user_test_data.volunteer)
assert len(resources) == 2
assert isinstance(resources[0], Resource)
def test_get_resources_admin(resource_svc: ResourceService):
""" Test getting resources by an admin """
resources = resource_svc.get_resource_by_user(user_test_data.admin)
assert len(resources) == len(resource_test_data.resources)
assert isinstance(resources[0], Resource)
def test_get_resources_employee(resource_svc: ResourceService):
""" Test getting by an employee """
resources = resource_svc.get_resource_by_user(user_test_data.employee)
assert len(resources) == 5
assert isinstance(resources[0], Resource)
def test_create_resource_admin(resource_svc: ResourceService):
""" Test creating resources as an admin """
resource = resource_svc.create(user_test_data.admin, resource_test_data.resource6)
assert resource.name == resource_test_data.resource6.name
assert isinstance(resource, Resource)
def test_create_not_permitted(resource_svc: ResourceService):
""" Test creating resources without permission """
with pytest.raises(PermissionError):
resource = resource_svc.create(user_test_data.volunteer, resource_test_data.resource1)
pytest.fail()
def test_get_by_id(resource_svc: ResourceService):
""" Test getting a resource by id as an admin """
test_resource = resource_test_data.resource1
resource = resource_svc.get_by_id(user_test_data.admin, test_resource.id)
assert resource is not None
assert resource.id == test_resource.id
assert resource.name == test_resource.name
def test_get_by_id_no_access(resource_svc: ResourceService):
""" Test getting a resourced with an id no accessible to an employee """
test_resource = resource_test_data.resource2
with pytest.raises(ResourceNotFoundException):
resource = resource_svc.get_by_id(user_test_data.employee, test_resource.id)
pytest.fail()
def test_update(resource_svc: ResourceService):
""" Test updating a resource by an admin """
updated_resource = resource_test_data.resource5_new
resource = resource_svc.update(user_test_data.admin, updated_resource)
db_resource = resource_svc.get_by_id(user_test_data.admin, resource.id)
assert resource.id == updated_resource.id
assert resource.name == updated_resource.name
assert resource.summary == updated_resource.summary
assert resource.link == updated_resource.link
assert resource.program == updated_resource.program
assert db_resource.id == updated_resource.id
assert db_resource.name == updated_resource.name
assert db_resource.summary == updated_resource.summary
assert db_resource.link == updated_resource.link
assert db_resource.program == updated_resource.program
def test_update_no_permission(resource_svc: ResourceService):
""" Test updating a resource without permission """
with pytest.raises(PermissionError):
resource = resource_svc.update(user_test_data.employee, resource_test_data.resource5_new)
pytest.fail()
def test_delete(resource_svc: ResourceService):
""" Test deleting a resource as an admin """
resource_svc.delete(user_test_data.admin, resource_test_data.resource5.id)
resources = resource_svc.get_resource_by_user(user_test_data.admin)
assert len(resources) == len(resource_test_data.resources) - 1
def test_delete_no_permission(resource_svc: ResourceService):
""" Test deleting a resource with no permission """
with pytest.raises(PermissionError):
resource = resource_svc.delete(user_test_data.employee, resource_test_data.resource5.id)
pytest.fail()
def test_get_1_by_slug(resource_svc: ResourceService):
""" Test getting 1 resource with a specific search """
resource_to_test = resource_test_data.resource1
slug = "Resource 1"
resources = resource_svc.get_by_slug(user_test_data.admin, slug)
assert len(resources) == 1
resource = resources[0]
assert resource.id == resource_to_test.id
assert resource.name == resource_to_test.name
assert resource.summary == resource_to_test.summary
assert resource.link == resource_to_test.link
assert resource.program == resource_to_test.program
def test_get_by_slug(resource_svc: ResourceService):
""" Test a generic search to get all resources """
slug = "Resource"
resources = resource_svc.get_by_slug(user_test_data.admin, slug)
assert len(resources) == 5
def test_get_by_slug_not_found(resource_svc: ResourceService):
""" Test getting a resource that does not exist """
slug = "Not Found"
resources = resource_svc.get_by_slug(user_test_data.admin, slug)
assert len(resources) == 0
assert resources == []
def test_get_by_slug_no_permission(resource_svc: ResourceService):
""" Test getting a resource the user does not have access to """
slug = "Resource 2"
resources = resource_svc.get_by_slug(user_test_data.employee, slug)
assert len(resources) == 0
assert resources == []

View File

@ -50,6 +50,24 @@ resource5 = Resource(
created_at=datetime(2023, 6, 5, 11, 30, 0), created_at=datetime(2023, 6, 5, 11, 30, 0),
) )
resource6 = Resource(
id=6,
name="Resource 6",
summary="New Financial Resource",
link="https://example.com/resource6",
program=ProgramTypeEnum.ECONOMIC,
created_at=datetime(2024, 6, 5, 11, 30, 0),
)
resource5_new = Resource(
id=5,
name="Resource 5",
summary = "Updated shelter and housing resources",
link="https://example.com/resource5/new",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 5, 11, 30, 0),
)
resources = [resource1, resource2, resource3, resource4, resource5] resources = [resource1, resource2, resource3, resource4, resource5]
resource_1 = Resource( resource_1 = Resource(
@ -266,13 +284,11 @@ def reset_table_id_seq(
next_id: int, next_id: int,
) -> None: ) -> None:
"""Reset the ID sequence of an entity table. """Reset the ID sequence of an entity table.
Args: Args:
session (Session) - A SQLAlchemy Session session (Session) - A SQLAlchemy Session
entity (DeclarativeBase) - The SQLAlchemy Entity table to target entity (DeclarativeBase) - The SQLAlchemy Entity table to target
entity_id_column (MappedColumn) - The ID column (should be an int column) entity_id_column (MappedColumn) - The ID column (should be an int column)
next_id (int) - Where the next inserted, autogenerated ID should begin next_id (int) - Where the next inserted, autogenerated ID should begin
Returns: Returns:
None""" None"""
table = entity.__table__ table = entity.__table__
@ -312,4 +328,4 @@ def insert_fake_data(session: Session):
reset_table_id_seq(session, ResourceEntity, ResourceEntity.id, len(resources) + 1) reset_table_id_seq(session, ResourceEntity, ResourceEntity.id, len(resources) + 1)
# Commit all changes # Commit all changes
session.commit() session.commit()

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { PageLayout } from "@/components/PageLayout"; import { PageLayout } from "@/components/PageLayout";
import { Table } from "@/components/Table/Index"; import UserTable from "@/components/Table/UserTable";
import User from "@/utils/models/User"; import User from "@/utils/models/User";
import { createClient } from "@/utils/supabase/client"; import { createClient } from "@/utils/supabase/client";
@ -38,7 +38,7 @@ export default function Page() {
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
{/* icon + title */} {/* icon + title */}
<PageLayout title="Users" icon={<UsersIcon />}> <PageLayout title="Users" icon={<UsersIcon />}>
<Table users={users} /> <UserTable data={users} setData={setUsers} />
</PageLayout> </PageLayout>
</div> </div>
); );

View File

@ -9,7 +9,7 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid"); const uuid = searchParams.get("uuid");
const data = await fetch(`${apiEndpoint}?user_id=${uuid}`); const data = await fetch(`${apiEndpoint}?uuid=${uuid}`);
const resourceData: Resource[] = await data.json(); const resourceData: Resource[] = await data.json();
// TODO: Remove make every resource visible // TODO: Remove make every resource visible

View File

@ -9,7 +9,7 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid"); const uuid = searchParams.get("uuid");
const data = await fetch(`${apiEndpoint}?user_id=${uuid}`); const data = await fetch(`${apiEndpoint}?uuid=${uuid}`);
const serviceData: Service[] = await data.json(); const serviceData: Service[] = await data.json();
// TODO: Remove make every service visible // TODO: Remove make every service visible

View File

@ -1,8 +1,8 @@
"use client"; "use client";
import { PageLayout } from "@/components/PageLayout"; import { PageLayout } from "@/components/PageLayout";
import { ResourceTable } from "@/components/Table/ResourceIndex";
import Resource from "@/utils/models/Resource"; import Resource from "@/utils/models/Resource";
import ResourceTable from "@/components/Table/ResourceTable";
import { createClient } from "@/utils/supabase/client"; import { createClient } from "@/utils/supabase/client";
import { BookmarkIcon } from "@heroicons/react/24/solid"; import { BookmarkIcon } from "@heroicons/react/24/solid";
@ -38,7 +38,7 @@ export default function Page() {
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
{/* icon + title */} {/* icon + title */}
<PageLayout title="Resources" icon={<BookmarkIcon />}> <PageLayout title="Resources" icon={<BookmarkIcon />}>
<ResourceTable users={resources} /> <ResourceTable data={resources} setData={setResources} />
</PageLayout> </PageLayout>
</div> </div>
); );

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { PageLayout } from "@/components/PageLayout"; import { PageLayout } from "@/components/PageLayout";
import { ServiceTable } from "@/components/Table/ServiceIndex"; import ServiceTable from "@/components/Table/ServiceTable";
import Service from "@/utils/models/Service"; import Service from "@/utils/models/Service";
import { createClient } from "@/utils/supabase/client"; import { createClient } from "@/utils/supabase/client";
@ -9,7 +9,7 @@ import { ClipboardIcon } from "@heroicons/react/24/solid";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export default function Page() { export default function Page() {
const [services, setUsers] = useState<Service[]>([]); const [services, setServices] = useState<Service[]>([]);
useEffect(() => { useEffect(() => {
async function getServices() { async function getServices() {
@ -27,7 +27,7 @@ export default function Page() {
); );
const servicesAPI: Service[] = await serviceListData.json(); const servicesAPI: Service[] = await serviceListData.json();
setUsers(servicesAPI); setServices(servicesAPI);
} }
getServices(); getServices();
@ -37,7 +37,7 @@ export default function Page() {
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
{/* icon + title */} {/* icon + title */}
<PageLayout title="Services" icon={<ClipboardIcon />}> <PageLayout title="Services" icon={<ClipboardIcon />}>
<ServiceTable users={services} /> <ServiceTable data={services} setData={setServices} />
</PageLayout> </PageLayout>
</div> </div>
); );

View File

@ -1,4 +1,4 @@
import { FunctionComponent, ReactNode } from "react"; import { Dispatch, FunctionComponent, ReactNode, SetStateAction } from "react";
import React, { useState } from "react"; import React, { useState } from "react";
import { ChevronDoubleLeftIcon } from "@heroicons/react/24/solid"; import { ChevronDoubleLeftIcon } from "@heroicons/react/24/solid";
import { import {
@ -23,7 +23,7 @@ type DrawerProps = {
editableContent?: any; editableContent?: any;
onSave?: (content: any) => void; onSave?: (content: any) => void;
rowContent?: any; rowContent?: any;
onRowUpdate?: (content: any) => void; setData: Dispatch<SetStateAction<any>>;
}; };
interface EditContent { interface EditContent {
@ -37,13 +37,21 @@ const Drawer: FunctionComponent<DrawerProps> = ({
onSave, onSave,
editableContent, editableContent,
rowContent, rowContent,
onRowUpdate, setData,
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isFull, setIsFull] = useState(false); const [isFull, setIsFull] = useState(false);
const [isFavorite, setIsFavorite] = useState(false); const [isFavorite, setIsFavorite] = useState(false);
const [tempRowContent, setTempRowContent] = useState(rowContent); const [tempRowContent, setTempRowContent] = useState(rowContent);
const onRowUpdate = (updatedRow: any) => {
setData((prevData: any) =>
prevData.map((row: any) =>
row.id === updatedRow.id ? updatedRow : row
)
);
};
const handleTempRowContentChange = (e) => { const handleTempRowContentChange = (e) => {
const { name, value } = e.target; const { name, value } = e.target;
console.log(name); console.log(name);

View File

@ -1,306 +0,0 @@
// for showcasing to compass
import users from "./users.json";
import {
Cell,
ColumnDef,
Row,
createColumnHelper,
flexRender,
getCoreRowModel,
getFilteredRowModel,
sortingFns,
useReactTable,
} from "@tanstack/react-table";
import {
ChangeEvent,
useState,
useEffect,
FunctionComponent,
useRef,
ChangeEventHandler,
Key,
} from "react";
import { RowOptionMenu } from "./RowOptionMenu";
import { RowOpenAction } from "./RowOpenAction";
import { TableAction } from "./TableAction";
import {
AtSymbolIcon,
Bars2Icon,
ArrowDownCircleIcon,
PlusIcon,
} from "@heroicons/react/24/solid";
import TagsInput from "../TagsInput/Index";
import { rankItem } from "@tanstack/match-sorter-utils";
import User from "@/utils/models/User";
// For search
const fuzzyFilter = (
row: Row<any>,
columnId: string,
value: any,
addMeta: (meta: any) => void
) => {
// Rank the item
const itemRank = rankItem(row.getValue(columnId), value);
// Store the ranking info
addMeta(itemRank);
// Return if the item should be filtered in/out
return itemRank.passed;
};
export const Table = ({ users }: { users: User[] }) => {
const columnHelper = createColumnHelper<User>();
useEffect(() => {
const sortedUsers = [...users].sort((a, b) =>
a.visible === b.visible ? 0 : a.visible ? -1 : 1
);
setData(sortedUsers);
}, [users]);
const deleteUser = (userId: number) => {
console.log(data);
setData((currentData) =>
currentData.filter((user) => user.id !== userId)
);
};
const hideUser = (userId: number) => {
console.log(`Toggling visibility for user with ID: ${userId}`);
setData((currentData) => {
const newData = currentData
.map((user) => {
if (user.id === userId) {
return { ...user, visible: !user.visible };
}
return user;
})
.sort((a, b) =>
a.visible === b.visible ? 0 : a.visible ? -1 : 1
);
console.log(newData);
return newData;
});
};
const [presetOptions, setPresetOptions] = useState([
"administrator",
"volunteer",
"employee",
]);
const [tagColors, setTagColors] = useState(new Map());
const getTagColor = (tag: string) => {
if (!tagColors.has(tag)) {
const colors = [
"bg-cyan-100",
"bg-blue-100",
"bg-green-100",
"bg-yellow-100",
"bg-purple-100",
];
const randomColor =
colors[Math.floor(Math.random() * colors.length)];
setTagColors(new Map(tagColors).set(tag, randomColor));
}
return tagColors.get(tag);
};
const columns = [
columnHelper.display({
id: "options",
cell: (props) => (
<RowOptionMenu
onDelete={() => deleteUser(props.row.original.id)}
onHide={() => hideUser(props.row.original.id)}
/>
),
}),
columnHelper.accessor("username", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Username
</>
),
cell: (info) => (
<RowOpenAction
title={info.getValue()}
rowData={info.row.original}
onRowUpdate={handleRowUpdate}
/>
),
}),
columnHelper.accessor("role", {
header: () => (
<>
<ArrowDownCircleIcon className="inline align-top h-4" />{" "}
Role
</>
),
cell: (info) => (
<TagsInput
presetValue={info.getValue()}
presetOptions={presetOptions}
setPresetOptions={setPresetOptions}
getTagColor={getTagColor}
setTagColors={setTagColors}
/>
),
}),
columnHelper.accessor("email", {
header: () => (
<>
<AtSymbolIcon className="inline align-top h-4" /> Email
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500 underline hover:text-gray-400">
{info.getValue()}
</span>
),
}),
columnHelper.accessor("program", {
header: () => (
<>
<ArrowDownCircleIcon className="inline align-top h-4" />{" "}
Program
</>
),
cell: (info) => <TagsInput presetValue={info.getValue()} />,
}),
];
const [data, setData] = useState<User[]>([...users]);
const addUser = () => {
setData([...data]);
};
// Searching
const [query, setQuery] = useState("");
const handleSearchChange = (e: ChangeEvent) => {
const target = e.target as HTMLInputElement;
setQuery(String(target.value));
};
const handleCellChange = (e: ChangeEvent, key: Key) => {
const target = e.target as HTMLInputElement;
console.log(key);
};
// TODO: Filtering
// TODO: Sorting
// added this fn for editing rows
const handleRowUpdate = (updatedRow: User) => {
const dataIndex = data.findIndex((row) => row.id === updatedRow.id);
if (dataIndex !== -1) {
const updatedData = [...data];
updatedData[dataIndex] = updatedRow;
setData(updatedData);
}
};
const table = useReactTable({
columns,
data,
filterFns: {
fuzzy: fuzzyFilter,
},
state: {
globalFilter: query,
},
onGlobalFilterChange: setQuery,
globalFilterFn: fuzzyFilter,
getCoreRowModel: getCoreRowModel(),
});
const handleRowData = (row: any) => {
const rowData: any = {};
row.cells.forEach((cell: any) => {
rowData[cell.column.id] = cell.value;
});
// Use rowData object containing data from all columns for the current row
console.log(rowData);
return rowData;
};
return (
<div className="flex flex-col">
<div className="flex flex-row justify-end">
<TableAction query={query} handleChange={handleSearchChange} />
</div>
<table className="w-full text-xs text-left rtl:text-right">
<thead className="text-xs text-gray-500 capitalize">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header, i) => (
<th
scope="col"
className={
"p-2 border-gray-200 border-y font-medium " +
(1 < i && i < columns.length - 1
? "border-x"
: "")
}
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => {
// Individual row
const isUserVisible = row.original.visible;
const rowClassNames = `text-gray-800 border-y lowercase hover:bg-gray-50 ${
!isUserVisible ? "bg-gray-200 text-gray-500" : ""
}`;
return (
<tr className={rowClassNames} key={row.id}>
{row.getVisibleCells().map((cell, i) => (
<td
key={cell.id}
className={
"[&:nth-child(n+3)]:border-x relative first:text-left first:px-0 last:border-none"
}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td
className="p-3 border-y border-gray-200 text-gray-600 hover:bg-gray-50"
colSpan={100}
onClick={addUser}
>
<span className="flex ml-1 text-gray-500">
<PlusIcon className="inline h-4 mr-1" />
New
</span>
</td>
</tr>
</tfoot>
</table>
</div>
);
};

View File

@ -0,0 +1,82 @@
import { Bars2Icon } from "@heroicons/react/24/solid";
import { Dispatch, SetStateAction, useState } from "react";
import useTagsHandler from "@/components/TagsInput/TagsHandler";
import { ColumnDef, createColumnHelper } from "@tanstack/react-table";
import { RowOpenAction } from "@/components/Table/RowOpenAction";
import Table from "@/components/Table/Table";
import TagsInput from "@/components/TagsInput/Index";
import Resource from "@/utils/models/Resource";
type ResourceTableProps = {
data: Resource[];
setData: Dispatch<SetStateAction<Resource[]>>;
};
/**
* Table componenet used for displaying resources
* @param props.data Stateful list of resources to be displayed by the table
* @param props.setData State setter for the list of resources
*/
export default function ResourceTable({ data, setData }: ResourceTableProps) {
const columnHelper = createColumnHelper<Resource>();
// Set up tag handling
const programProps = useTagsHandler(["community", "domestic", "economic"]);
// Define Tanstack columns
const columns: ColumnDef<Resource, any>[] = [
columnHelper.accessor("name", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Name
</>
),
cell: (info) => (
<RowOpenAction
title={info.getValue()}
rowData={info.row.original}
setData={setData}
/>
),
}),
columnHelper.accessor("link", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Link
</>
),
cell: (info) => (
<a
href={info.getValue()}
target={"_blank"}
className="ml-2 text-gray-500 underline hover:text-gray-400"
>
{info.getValue()}
</a>
),
}),
columnHelper.accessor("program", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Program
</>
),
cell: (info) => (
<TagsInput presetValue={info.getValue()} {...programProps} />
),
}),
columnHelper.accessor("summary", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Summary
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500">{info.getValue()}</span>
),
}),
];
return <Table data={data} setData={setData} columns={columns} />;
}

View File

@ -1,10 +1,21 @@
import Drawer from "@/components/Drawer/Drawer"; import Drawer from "@/components/Drawer/Drawer";
import { ChangeEvent, useState } from "react"; import DataPoint from "@/utils/models/DataPoint";
import { Dispatch, SetStateAction, useState } from "react";
export const RowOpenAction = ({ title, rowData, onRowUpdate }) => { type RowOpenActionProps<T extends DataPoint> = {
title: string;
rowData: T;
setData: Dispatch<SetStateAction<T[]>>;
};
export function RowOpenAction<T extends DataPoint>({
title,
rowData,
setData,
}: RowOpenActionProps<T>) {
const [pageContent, setPageContent] = useState(""); const [pageContent, setPageContent] = useState("");
const handleDrawerContentChange = (newContent) => { const handleDrawerContentChange = (newContent: string) => {
setPageContent(newContent); setPageContent(newContent);
}; };
@ -12,17 +23,16 @@ export const RowOpenAction = ({ title, rowData, onRowUpdate }) => {
<div className="font-semibold group flex flex-row items-center justify-between pr-2"> <div className="font-semibold group flex flex-row items-center justify-between pr-2">
{title} {title}
<span> <span>
{/* Added OnRowUpdate to drawer */}
<Drawer <Drawer
title="My Drawer Title" title="My Drawer Title"
editableContent={pageContent} editableContent={pageContent}
rowContent={rowData} rowContent={rowData}
onSave={handleDrawerContentChange} onSave={handleDrawerContentChange}
onRowUpdate={onRowUpdate} setData={setData}
> >
{pageContent} {pageContent}
</Drawer> </Drawer>
</span> </span>
</div> </div>
); );
}; }

View File

@ -1,312 +0,0 @@
// for showcasing to compass
import users from "./users.json";
import {
Cell,
ColumnDef,
Row,
createColumnHelper,
flexRender,
getCoreRowModel,
getFilteredRowModel,
sortingFns,
useReactTable,
} from "@tanstack/react-table";
import {
ChangeEvent,
useState,
useEffect,
FunctionComponent,
useRef,
ChangeEventHandler,
Key,
} from "react";
import { RowOptionMenu } from "./RowOptionMenu";
import { RowOpenAction } from "./RowOpenAction";
import { TableAction } from "./TableAction";
import {
AtSymbolIcon,
Bars2Icon,
ArrowDownCircleIcon,
PlusIcon,
} from "@heroicons/react/24/solid";
import TagsInput from "../TagsInput/Index";
import { rankItem } from "@tanstack/match-sorter-utils";
import Service from "@/utils/models/Service";
// For search
const fuzzyFilter = (
row: Row<any>,
columnId: string,
value: any,
addMeta: (meta: any) => void
) => {
// Rank the item
const itemRank = rankItem(row.getValue(columnId), value);
// Store the ranking info
addMeta(itemRank);
// Return if the item should be filtered in/out
return itemRank.passed;
};
// TODO: Rename everything to service
export const ServiceTable = ({ users }: { users: Service[] }) => {
const columnHelper = createColumnHelper<Service>();
useEffect(() => {
const sortedUsers = [...users].sort((a, b) =>
a.visible === b.visible ? 0 : a.visible ? -1 : 1
);
setData(sortedUsers);
}, [users]);
const deleteUser = (userId: number) => {
console.log(data);
setData((currentData) =>
currentData.filter((user) => user.id !== userId)
);
};
const hideUser = (userId: number) => {
console.log(`Toggling visibility for user with ID: ${userId}`);
setData((currentData) => {
const newData = currentData
.map((user) => {
if (user.id === userId) {
return { ...user, visible: !user.visible };
}
return user;
})
.sort((a, b) =>
a.visible === b.visible ? 0 : a.visible ? -1 : 1
);
console.log(newData);
return newData;
});
};
const [presetOptions, setPresetOptions] = useState([
"administrator",
"volunteer",
"employee",
]);
const [tagColors, setTagColors] = useState(new Map());
const getTagColor = (tag: string) => {
if (!tagColors.has(tag)) {
const colors = [
"bg-cyan-100",
"bg-blue-100",
"bg-green-100",
"bg-yellow-100",
"bg-purple-100",
];
const randomColor =
colors[Math.floor(Math.random() * colors.length)];
setTagColors(new Map(tagColors).set(tag, randomColor));
}
return tagColors.get(tag);
};
const columns = [
columnHelper.display({
id: "options",
cell: (props) => (
<RowOptionMenu
onDelete={() => {}}
onHide={() => hideUser(props.row.original.id)}
/>
),
}),
columnHelper.accessor("name", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Name
</>
),
cell: (info) => (
<RowOpenAction
title={info.getValue()}
rowData={info.row.original}
onRowUpdate={handleRowUpdate}
/>
),
}),
columnHelper.accessor("status", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Status
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500">{info.getValue()}</span>
),
}),
columnHelper.accessor("program", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Program
</>
),
cell: (info) => <TagsInput presetValue={info.getValue()} />,
}),
columnHelper.accessor("requirements", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Requirements
</>
),
cell: (info) => (
<TagsInput
presetValue={
info.getValue()[0] !== "" ? info.getValue() : ["N/A"]
}
/>
),
}),
columnHelper.accessor("summary", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Summary
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500">{info.getValue()}</span>
),
}),
];
const [data, setData] = useState<Service[]>([...users]);
const addUser = () => {
setData([...data]);
};
// Searching
const [query, setQuery] = useState("");
const handleSearchChange = (e: ChangeEvent) => {
const target = e.target as HTMLInputElement;
setQuery(String(target.value));
};
const handleCellChange = (e: ChangeEvent, key: Key) => {
const target = e.target as HTMLInputElement;
console.log(key);
};
// TODO: Filtering
// TODO: Sorting
// added this fn for editing rows
const handleRowUpdate = (updatedRow: Service) => {
const dataIndex = data.findIndex((row) => row.id === updatedRow.id);
if (dataIndex !== -1) {
const updatedData = [...data];
updatedData[dataIndex] = updatedRow;
setData(updatedData);
}
};
const table = useReactTable({
columns,
data,
filterFns: {
fuzzy: fuzzyFilter,
},
state: {
globalFilter: query,
},
onGlobalFilterChange: setQuery,
globalFilterFn: fuzzyFilter,
getCoreRowModel: getCoreRowModel(),
});
const handleRowData = (row: any) => {
const rowData: any = {};
row.cells.forEach((cell: any) => {
rowData[cell.column.id] = cell.value;
});
// Use rowData object containing data from all columns for the current row
console.log(rowData);
return rowData;
};
return (
<div className="flex flex-col">
<div className="flex flex-row justify-end">
<TableAction query={query} handleChange={handleSearchChange} />
</div>
<table className="w-full text-xs text-left rtl:text-right">
<thead className="text-xs text-gray-500 capitalize">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header, i) => (
<th
scope="col"
className={
"p-2 border-gray-200 border-y font-medium " +
(1 < i && i < columns.length - 1
? "border-x"
: "")
}
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => {
// Individual row
const isUserVisible = row.original.visible;
const rowClassNames = `text-gray-800 border-y lowercase hover:bg-gray-50 ${
!isUserVisible ? "bg-gray-200 text-gray-500" : ""
}`;
return (
<tr className={rowClassNames} key={row.id}>
{row.getVisibleCells().map((cell, i) => (
<td
key={cell.id}
className={
"[&:nth-child(n+3)]:border-x relative first:text-left first:px-0 last:border-none"
}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td
className="p-3 border-y border-gray-200 text-gray-600 hover:bg-gray-50"
colSpan={100}
onClick={addUser}
>
<span className="flex ml-1 text-gray-500">
<PlusIcon className="inline h-4 mr-1" />
New
</span>
</td>
</tr>
</tfoot>
</table>
</div>
);
};

View File

@ -0,0 +1,103 @@
import { Bars2Icon } from "@heroicons/react/24/solid";
import { Dispatch, SetStateAction } from "react";
import useTagsHandler from "@/components/TagsInput/TagsHandler";
import { ColumnDef, createColumnHelper } from "@tanstack/react-table";
import Table from "@/components/Table/Table";
import { RowOpenAction } from "@/components/Table/RowOpenAction";
import TagsInput from "@/components/TagsInput/Index";
import Service from "@/utils/models/Service";
type ServiceTableProps = {
data: Service[];
setData: Dispatch<SetStateAction<Service[]>>;
};
/**
* Table componenet used for displaying services
* @param props.data Stateful list of services to be displayed by the table
* @param props.setData State setter for the list of services
*/
export default function ServiceTable({ data, setData }: ServiceTableProps) {
const columnHelper = createColumnHelper<Service>();
// Set up tag handling
const programProps = useTagsHandler(["community", "domestic", "economic"]);
// TODO: Dynamically or statically get full list of preset requirement tag options
const requirementProps = useTagsHandler([
"anonymous",
"confidential",
"referral required",
"safety assessment",
"intake required",
"income eligibility",
"initial assessment",
]);
// Define Tanstack columns
const columns: ColumnDef<Service, any>[] = [
columnHelper.accessor("name", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Name
</>
),
cell: (info) => (
<RowOpenAction
title={info.getValue()}
rowData={info.row.original}
setData={setData}
/>
),
}),
columnHelper.accessor("status", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Status
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500">{info.getValue()}</span>
),
}),
columnHelper.accessor("program", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Program
</>
),
cell: (info) => (
<TagsInput presetValue={info.getValue()} {...programProps} />
),
}),
columnHelper.accessor("requirements", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Requirements
</>
),
cell: (info) => (
// TODO: Setup different tag handler for requirements
<TagsInput
presetValue={
info.getValue()[0] !== "" ? info.getValue() : ["N/A"]
}
{...requirementProps}
/>
),
}),
columnHelper.accessor("summary", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Summary
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500">{info.getValue()}</span>
),
}),
];
return <Table data={data} setData={setData} columns={columns} />;
}

View File

@ -1,40 +1,32 @@
// for showcasing to compass
import users from "./users.json";
import { import {
Cell,
ColumnDef,
Row, Row,
createColumnHelper, ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
sortingFns,
useReactTable, useReactTable,
getCoreRowModel,
flexRender,
createColumnHelper,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { import {
ChangeEvent, ChangeEvent,
useState, useState,
useEffect, useEffect,
FunctionComponent,
useRef,
ChangeEventHandler,
Key, Key,
Dispatch,
SetStateAction,
} from "react"; } from "react";
import { RowOptionMenu } from "./RowOptionMenu";
import { RowOpenAction } from "./RowOpenAction";
import { TableAction } from "./TableAction"; import { TableAction } from "./TableAction";
import { import { PlusIcon } from "@heroicons/react/24/solid";
AtSymbolIcon,
Bars2Icon,
ArrowDownCircleIcon,
PlusIcon,
} from "@heroicons/react/24/solid";
import TagsInput from "../TagsInput/Index";
import { rankItem } from "@tanstack/match-sorter-utils"; import { rankItem } from "@tanstack/match-sorter-utils";
import Resource from "@/utils/models/Resource"; import { RowOptionMenu } from "./RowOptionMenu";
import DataPoint from "@/utils/models/DataPoint";
// For search type TableProps<T extends DataPoint> = {
data: T[];
setData: Dispatch<SetStateAction<T[]>>;
columns: ColumnDef<T, any>[];
};
/** Fuzzy search function */
const fuzzyFilter = ( const fuzzyFilter = (
row: Row<any>, row: Row<any>,
columnId: string, columnId: string,
@ -51,131 +43,69 @@ const fuzzyFilter = (
return itemRank.passed; return itemRank.passed;
}; };
// TODO: Rename everything to resources /**
export const ResourceTable = ({ users }: { users: Resource[] }) => { * General componenet that holds shared functionality for any data table component
const columnHelper = createColumnHelper<Resource>(); * @param props.data Stateful list of data to be held in the table
* @param props.setData State setter for the list of data
* @param props.columns Column definitions made with Tanstack columnHelper
*/
export default function Table<T extends DataPoint>({
data,
setData,
columns,
}: TableProps<T>) {
const columnHelper = createColumnHelper<T>();
/** Sorting function based on visibility */
const visibilitySort = (a: T, b: T) =>
a.visible === b.visible ? 0 : a.visible ? -1 : 1;
// Sort data on load
useEffect(() => { useEffect(() => {
const sortedUsers = [...users].sort((a, b) => setData((prevData) => prevData.sort(visibilitySort));
a.visible === b.visible ? 0 : a.visible ? -1 : 1 }, [setData]);
);
setData(sortedUsers);
}, [users]);
const deleteUser = (userId: number) => { // Data manipulation methods
// TODO: Connect data manipulation methods to the database (deleteData, hideData, addData)
const deleteData = (dataId: number) => {
console.log(data); console.log(data);
setData((currentData) => setData((currentData) =>
currentData.filter((user) => user.id !== userId) currentData.filter((data) => data.id !== dataId)
); );
}; };
const hideUser = (userId: number) => { const hideData = (dataId: number) => {
console.log(`Toggling visibility for user with ID: ${userId}`); console.log(`Toggling visibility for data with ID: ${dataId}`);
setData((currentData) => { setData((currentData) => {
const newData = currentData const newData = currentData
.map((user) => { .map((data) =>
if (user.id === userId) { data.id === dataId
return { ...user, visible: !user.visible }; ? { ...data, visible: !data.visible }
} : data
return user; )
}) .sort(visibilitySort);
.sort((a, b) =>
a.visible === b.visible ? 0 : a.visible ? -1 : 1
);
console.log(newData); console.log(newData);
return newData; return newData;
}); });
}; };
const [presetOptions, setPresetOptions] = useState([
"administrator",
"volunteer",
"employee",
]);
const [tagColors, setTagColors] = useState(new Map());
const getTagColor = (tag: string) => { const addData = () => {
if (!tagColors.has(tag)) { setData([...data]);
const colors = [
"bg-cyan-100",
"bg-blue-100",
"bg-green-100",
"bg-yellow-100",
"bg-purple-100",
];
const randomColor =
colors[Math.floor(Math.random() * colors.length)];
setTagColors(new Map(tagColors).set(tag, randomColor));
}
return tagColors.get(tag);
}; };
const columns = [ // Add data manipulation options to the first column
columns.unshift(
columnHelper.display({ columnHelper.display({
id: "options", id: "options",
cell: (props) => ( cell: (props) => (
<RowOptionMenu <RowOptionMenu
onDelete={() => {}} onDelete={() => deleteData(props.row.original.id)}
onHide={() => hideUser(props.row.original.id)} onHide={() => hideData(props.row.original.id)}
/> />
), ),
}), })
columnHelper.accessor("name", { );
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Name
</>
),
cell: (info) => (
<RowOpenAction
title={info.getValue()}
rowData={info.row.original}
onRowUpdate={handleRowUpdate}
/>
),
}),
columnHelper.accessor("link", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Link
</>
),
cell: (info) => (
<a
href={info.getValue()}
target={"_blank"}
className="ml-2 text-gray-500 underline hover:text-gray-400"
>
{info.getValue()}
</a>
),
}),
columnHelper.accessor("program", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Program
</>
),
cell: (info) => <TagsInput presetValue={info.getValue()} />,
}),
columnHelper.accessor("summary", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Summary
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500">{info.getValue()}</span>
),
}),
];
const [data, setData] = useState<Resource[]>([...users]);
const addUser = () => {
setData([...data]);
};
// Searching // Searching
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@ -193,16 +123,7 @@ export const ResourceTable = ({ users }: { users: Resource[] }) => {
// TODO: Sorting // TODO: Sorting
// added this fn for editing rows // Define Tanstack table
const handleRowUpdate = (updatedRow: Resource) => {
const dataIndex = data.findIndex((row) => row.id === updatedRow.id);
if (dataIndex !== -1) {
const updatedData = [...data];
updatedData[dataIndex] = updatedRow;
setData(updatedData);
}
};
const table = useReactTable({ const table = useReactTable({
columns, columns,
data, data,
@ -261,9 +182,9 @@ export const ResourceTable = ({ users }: { users: Resource[] }) => {
<tbody> <tbody>
{table.getRowModel().rows.map((row) => { {table.getRowModel().rows.map((row) => {
// Individual row // Individual row
const isUserVisible = row.original.visible; const isDataVisible = row.original.visible;
const rowClassNames = `text-gray-800 border-y lowercase hover:bg-gray-50 ${ const rowClassNames = `text-gray-800 border-y lowercase hover:bg-gray-50 ${
!isUserVisible ? "bg-gray-200 text-gray-500" : "" !isDataVisible ? "bg-gray-200 text-gray-500" : ""
}`; }`;
return ( return (
<tr className={rowClassNames} key={row.id}> <tr className={rowClassNames} key={row.id}>
@ -289,7 +210,7 @@ export const ResourceTable = ({ users }: { users: Resource[] }) => {
<td <td
className="p-3 border-y border-gray-200 text-gray-600 hover:bg-gray-50" className="p-3 border-y border-gray-200 text-gray-600 hover:bg-gray-50"
colSpan={100} colSpan={100}
onClick={addUser} onClick={addData}
> >
<span className="flex ml-1 text-gray-500"> <span className="flex ml-1 text-gray-500">
<PlusIcon className="inline h-4 mr-1" /> <PlusIcon className="inline h-4 mr-1" />
@ -301,4 +222,4 @@ export const ResourceTable = ({ users }: { users: Resource[] }) => {
</table> </table>
</div> </div>
); );
}; }

View File

@ -0,0 +1,89 @@
import {
ArrowDownCircleIcon,
AtSymbolIcon,
Bars2Icon,
} from "@heroicons/react/24/solid";
import { Dispatch, SetStateAction } from "react";
import useTagsHandler from "@/components/TagsInput/TagsHandler";
import { ColumnDef, createColumnHelper } from "@tanstack/react-table";
import Table from "@/components/Table/Table";
import { RowOpenAction } from "@/components/Table/RowOpenAction";
import TagsInput from "@/components/TagsInput/Index";
import User from "@/utils/models/User";
type UserTableProps = {
data: User[];
setData: Dispatch<SetStateAction<User[]>>;
};
/**
* Table componenet used for displaying users
* @param props.data Stateful list of users to be displayed by the table
* @param props.setData State setter for the list of users
*/
export default function UserTable({ data, setData }: UserTableProps) {
const columnHelper = createColumnHelper<User>();
// Set up tag handling
const roleProps = useTagsHandler([
"administrator",
"volunteer",
"employee",
]);
const programProps = useTagsHandler(["community", "domestic", "economic"]);
// Define Tanstack columns
const columns: ColumnDef<User, any>[] = [
columnHelper.accessor("username", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Username
</>
),
cell: (info) => (
<RowOpenAction
title={info.getValue()}
rowData={info.row.original}
setData={setData}
/>
),
}),
columnHelper.accessor("role", {
header: () => (
<>
<ArrowDownCircleIcon className="inline align-top h-4" />{" "}
Role
</>
),
cell: (info) => (
<TagsInput presetValue={info.getValue()} {...roleProps} />
),
}),
columnHelper.accessor("email", {
header: () => (
<>
<AtSymbolIcon className="inline align-top h-4" /> Email
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500 underline hover:text-gray-400">
{info.getValue()}
</span>
),
}),
columnHelper.accessor("program", {
header: () => (
<>
<ArrowDownCircleIcon className="inline align-top h-4" />{" "}
Program
</>
),
cell: (info) => (
<TagsInput presetValue={info.getValue()} {...programProps} />
),
}),
];
return <Table<User> data={data} setData={setData} columns={columns} />;
}

View File

@ -1,4 +1,4 @@
import React, { useState, useRef } from "react"; import React, { useState, useRef, Dispatch, SetStateAction } from "react";
import "tailwindcss/tailwind.css"; import "tailwindcss/tailwind.css";
import { TagsArray } from "./TagsArray"; import { TagsArray } from "./TagsArray";
import { TagDropdown } from "./TagDropdown"; import { TagDropdown } from "./TagDropdown";
@ -7,8 +7,8 @@ import { CreateNewTagAction } from "./CreateNewTagAction";
interface TagsInputProps { interface TagsInputProps {
presetOptions: string[]; presetOptions: string[];
presetValue: string | string[]; presetValue: string | string[];
setPresetOptions: () => {}; setPresetOptions: Dispatch<SetStateAction<string[]>>;
getTagColor: () => {}; getTagColor(tag: string): string;
} }
const TagsInput: React.FC<TagsInputProps> = ({ const TagsInput: React.FC<TagsInputProps> = ({

View File

@ -7,7 +7,7 @@ export interface Tags {
} }
export const TagsArray = ({ tags, handleDelete, active = false }: Tags) => { export const TagsArray = ({ tags, handleDelete, active = false }: Tags) => {
console.log(tags); // console.log(tags);
return ( return (
<div className="flex ml-2 flex-wrap gap-2 items-center"> <div className="flex ml-2 flex-wrap gap-2 items-center">

View File

@ -0,0 +1,35 @@
import { useState } from "react";
/**
* Custom hook used to handle the state of tag options and colors
* @param initialOptions Initial value for preset options
* @returns An object with three fields intended to be passed into a `TagsInput` component:
* - `presetOptions` - the current state of tag options
* - `setPresetOptions` - the state setter for presetOptions
* - `getTagColor` - function that retrieves the color for the given tag
*/
export default function useTagsHandler(initialOptions: string[]) {
const [presetOptions, setPresetOptions] = useState(initialOptions);
const [tagColors, setTagColors] = useState(new Map<string, string>());
const getTagColor = (tag: string): string => {
if (!tagColors.has(tag)) {
const colors = [
"bg-cyan-100",
"bg-blue-100",
"bg-green-100",
"bg-yellow-100",
"bg-purple-100",
];
const randomColor =
colors[Math.floor(Math.random() * colors.length)];
setTagColors(new Map(tagColors).set(tag, randomColor));
return randomColor;
}
// Since we populate any missing keys, .get will never return undefined,
// so we are safe to typecast to prevent a type error
return tagColors.get(tag) as string;
};
return { presetOptions, setPresetOptions, getTagColor };
}

View File

@ -0,0 +1,9 @@
/**
* Represents metadata of the Resource, Service, and User models to be used in a table
*/
interface DataPoint {
id: number;
visible: boolean;
}
export default DataPoint;