diff --git a/epictrack-api/src/api/models/region.py b/epictrack-api/src/api/models/region.py index f49ddcabb..cfe846e51 100644 --- a/epictrack-api/src/api/models/region.py +++ b/epictrack-api/src/api/models/region.py @@ -1,35 +1,40 @@ -# Copyright © 2019 Province of British Columbia -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""Model to handle all operations related to Region.""" - -from sqlalchemy import Column, Integer, String - -from .code_table import CodeTableVersioned -from .db import db - - -class Region(db.Model, CodeTableVersioned): - """Model class for Region.""" - - __tablename__ = 'regions' - - id = Column(Integer, primary_key=True, autoincrement=True) # TODO check how it can be inherited from parent - entity = Column(String()) - sort_order = Column(Integer, nullable=False) - - def as_dict(self): - """Return Json representation.""" - result = CodeTableVersioned.as_dict(self) - result['entity'] = self.entity - return result +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Model to handle all operations related to Region.""" + +from sqlalchemy import Column, Integer, String + +from .code_table import CodeTableVersioned +from .db import db + + +class Region(db.Model, CodeTableVersioned): + """Model class for Region.""" + + __tablename__ = 'regions' + + id = Column(Integer, primary_key=True, autoincrement=True) # TODO check how it can be inherited from parent + entity = Column(String()) + sort_order = Column(Integer, nullable=False) + + @classmethod + def find_all_by_region_type(cls, region_type: str): + """Find all regions by region type.""" + return cls.query.filter_by(entity=region_type).all() + + def as_dict(self): + """Return Json representation.""" + result = CodeTableVersioned.as_dict(self) + result['entity'] = self.entity + return result diff --git a/epictrack-api/src/api/models/staff.py b/epictrack-api/src/api/models/staff.py index 2e9c34983..d930a043e 100644 --- a/epictrack-api/src/api/models/staff.py +++ b/epictrack-api/src/api/models/staff.py @@ -1,97 +1,97 @@ -# Copyright © 2019 Province of British Columbia -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""Model to handle all operations related to Staff.""" - -from typing import List - -from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, func -from sqlalchemy.orm import column_property, relationship - -from api.models.base_model import BaseModelVersioned - - -class Staff(BaseModelVersioned): - """Model class for Staff.""" - - __tablename__ = "staffs" - - id = Column(Integer, primary_key=True, autoincrement=True) - first_name = Column(String(100), nullable=False) - last_name = Column(String(100), nullable=False) - phone = Column(String(), nullable=False) - email = Column(String(), nullable=False) - is_active = Column(Boolean(), default=True, nullable=False) - position_id = Column(ForeignKey("positions.id"), nullable=False) - is_deleted = Column(Boolean(), default=False, nullable=False) - - position = relationship("Position", foreign_keys=[position_id], lazy="select") - - full_name = column_property(last_name + ", " + first_name) - - def as_dict(self): # pylint: disable=arguments-differ - """Return Json representation.""" - return { - "id": self.id, - "first_name": self.first_name, - "last_name": self.last_name, - "full_name": self.full_name, - "phone": self.phone, - "email": self.email, - "is_active": self.is_active, - "position_id": self.position_id, - "position": self.position.as_dict(), - } - - @classmethod - def find_active_staff_by_position(cls, position_id: int): - """Return active staff by position id.""" - return cls.query.filter_by(position_id=position_id, is_active=True) - - @classmethod - def find_active_staff_by_positions(cls, position_ids: List[int]): - """Return active staffs by position ids.""" - return cls.query.filter( - Staff.position_id.in_(position_ids), Staff.is_active.is_(True) - ) - - @classmethod - def find_all_active_staff(cls): - """Return all active staff.""" - return cls.query.filter_by(is_active=True, is_deleted=False) - - @classmethod - def find_all_non_deleted_staff(cls, is_active=False): - """Return all non-deleted staff""" - query = {"is_deleted": False} - if is_active: - query["is_active"] = is_active - return cls.query.filter_by(**query) - - @classmethod - def check_existence(cls, email, staff_id): - """Checks if a staff exists with given email address""" - query = cls.query.filter( - func.lower(Staff.email) == func.lower(email), - Staff.is_deleted.is_(False), - ) - if staff_id: - query = query.filter(Staff.id != staff_id) - if query.count() > 0: - return True - return False - - @classmethod - def find_by_email(cls, email): - """Returns a staff by email""" - return cls.query.filter(Staff.email == email).first() +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Model to handle all operations related to Staff.""" + +from typing import List + +from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, func +from sqlalchemy.orm import column_property, relationship + +from api.models.base_model import BaseModelVersioned + + +class Staff(BaseModelVersioned): + """Model class for Staff.""" + + __tablename__ = "staffs" + + id = Column(Integer, primary_key=True, autoincrement=True) + first_name = Column(String(100), nullable=False) + last_name = Column(String(100), nullable=False) + phone = Column(String(), nullable=False) + email = Column(String(), nullable=False) + is_active = Column(Boolean(), default=True, nullable=False) + position_id = Column(ForeignKey("positions.id"), nullable=False) + is_deleted = Column(Boolean(), default=False, nullable=False) + + position = relationship("Position", foreign_keys=[position_id], lazy="select") + + full_name = column_property(last_name + ", " + first_name) + + def as_dict(self): # pylint: disable=arguments-differ + """Return Json representation.""" + return { + "id": self.id, + "first_name": self.first_name, + "last_name": self.last_name, + "full_name": self.full_name, + "phone": self.phone, + "email": self.email, + "is_active": self.is_active, + "position_id": self.position_id, + "position": self.position.as_dict(), + } + + @classmethod + def find_active_staff_by_position(cls, position_id: int): + """Return active staff by position id.""" + return cls.query.filter_by(position_id=position_id, is_active=True) + + @classmethod + def find_active_staff_by_positions(cls, position_ids: List[int]): + """Return active staffs by position ids.""" + return cls.query.filter( + Staff.position_id.in_(position_ids), Staff.is_active.is_(True) + ) + + @classmethod + def find_all_active_staff(cls): + """Return all active staff.""" + return cls.query.filter_by(is_active=True, is_deleted=False) + + @classmethod + def find_all_non_deleted_staff(cls, is_active=False): + """Return all non-deleted staff""" + query = {"is_deleted": False} + if is_active: + query["is_active"] = is_active + return cls.query.filter_by(**query) + + @classmethod + def check_existence(cls, email, staff_id): + """Checks if a staff exists with given email address""" + query = cls.query.filter( + func.lower(Staff.email) == func.lower(email), + Staff.is_deleted.is_(False), + ) + if staff_id: + query = query.filter(Staff.id != staff_id) + if query.count() > 0: + return True + return False + + @classmethod + def find_by_email(cls, email): + """Returns a staff by email""" + return cls.query.filter(Staff.email == email).first() diff --git a/epictrack-api/src/api/models/staff_work_role.py b/epictrack-api/src/api/models/staff_work_role.py index 5eb731677..6c37c89f1 100644 --- a/epictrack-api/src/api/models/staff_work_role.py +++ b/epictrack-api/src/api/models/staff_work_role.py @@ -1,49 +1,49 @@ -# Copyright © 2019 Province of British Columbia -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""Model to handle all operations related to StaffWorkRole.""" - -from sqlalchemy import Boolean, Column, ForeignKey, Integer -from sqlalchemy.orm import relationship - -from .base_model import BaseModelVersioned - - -class StaffWorkRole(BaseModelVersioned): - """Model class for StaffWorkRole.""" - - __tablename__ = 'staff_work_roles' - - id = Column(Integer, primary_key=True, autoincrement=True) - is_deleted = Column(Boolean(), default=False, nullable=False) - work_id = Column(ForeignKey('works.id'), nullable=False) - role_id = Column(ForeignKey('roles.id'), nullable=False) - staff_id = Column(ForeignKey('staffs.id'), nullable=False) - - work = relationship('Work', foreign_keys=[work_id], lazy='select') - role = relationship('Role', foreign_keys=[role_id], lazy='select') - staff = relationship('Staff', foreign_keys=[staff_id], lazy='select') - - def as_dict(self): # pylint:disable=arguments-differ - """Return Json representation.""" - return { - 'id': self.id, - 'work_id': self.work_id, - 'role': self.role.as_dict(), - 'staff': self.staff.as_dict() - } - - @classmethod - def find_by_work_id(cls, work_id: int): - """Return by work id.""" - return cls.query.filter_by(work_id=work_id) +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Model to handle all operations related to StaffWorkRole.""" + +from sqlalchemy import Boolean, Column, ForeignKey, Integer +from sqlalchemy.orm import relationship + +from .base_model import BaseModelVersioned + + +class StaffWorkRole(BaseModelVersioned): + """Model class for StaffWorkRole.""" + + __tablename__ = 'staff_work_roles' + + id = Column(Integer, primary_key=True, autoincrement=True) + is_deleted = Column(Boolean(), default=False, nullable=False) + work_id = Column(ForeignKey('works.id'), nullable=False) + role_id = Column(ForeignKey('roles.id'), nullable=False) + staff_id = Column(ForeignKey('staffs.id'), nullable=False) + + work = relationship('Work', foreign_keys=[work_id], lazy='select') + role = relationship('Role', foreign_keys=[role_id], lazy='select') + staff = relationship('Staff', foreign_keys=[staff_id], lazy='select') + + def as_dict(self): # pylint:disable=arguments-differ + """Return Json representation.""" + return { + 'id': self.id, + 'work_id': self.work_id, + 'role': self.role.as_dict(), + 'staff': self.staff.as_dict() + } + + @classmethod + def find_by_work_id(cls, work_id: int): + """Return by work id.""" + return cls.query.filter_by(work_id=work_id) diff --git a/epictrack-api/src/api/models/work.py b/epictrack-api/src/api/models/work.py index 4ab89bd8e..10e7c2da1 100644 --- a/epictrack-api/src/api/models/work.py +++ b/epictrack-api/src/api/models/work.py @@ -20,6 +20,9 @@ from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, String, Text, func from sqlalchemy.orm import relationship +from api.models.project import Project +from api.models.staff_work_role import StaffWorkRole + from .base_model import BaseModelVersioned from .pagination_options import PaginationOptions @@ -109,9 +112,16 @@ def check_existence(cls, title, work_id=None): return False @classmethod - def fetch_all_works(cls, pagination_options: PaginationOptions) -> Tuple[List[Work], int]: + def fetch_all_works( + cls, + pagination_options: PaginationOptions, + search_filters: dict = None + ) -> Tuple[List[Work], int]: """Fetch all active works.""" query = cls.query.filter_by(is_active=True, is_deleted=False) + + query = cls.filter_by_search_criteria(query, search_filters) + no_pagination_options = not pagination_options or not pagination_options.page or not pagination_options.size if no_pagination_options: items = query.all() @@ -120,3 +130,85 @@ def fetch_all_works(cls, pagination_options: PaginationOptions) -> Tuple[List[Wo page = query.paginate(page=pagination_options.page, per_page=pagination_options.size) return page.items, page.total + + @classmethod + def filter_by_search_criteria(cls, query, search_filters: dict): + """Filter by search criteria.""" + if not search_filters: + return query + + query = cls._filter_by_search_text(query, search_filters.get('search_text')) + + query = cls._filter_by_eao_team(query, search_filters.get('teams')) + + query = cls._filter_by_work_type(query, search_filters.get('work_type_ids')) + + query = cls._filter_by_project_type(query, search_filters.get('project_type_ids')) + + query = cls._filter_by_env_regions(query, search_filters.get('env_regions')) + + query = cls._filter_by_work_states(query, search_filters.get('work_states')) + + return query + + @classmethod + def _filter_by_staff_id(cls, query, staff_id): + if staff_id: + subquery = ( + cls.query + .filter(StaffWorkRole.staff_id == staff_id) + .exists() + ) + query = query.filter(subquery) + return query + + @classmethod + def _filter_by_search_text(cls, query, search_text): + if search_text: + query = query.filter(Work.title.ilike(f'%{search_text}%')) + return query + + @classmethod + def _filter_by_eao_team(cls, query, team_ids): + if team_ids: + subquery = ( + Project.query + .filter(Project.eao_team_id.in_(team_ids)) + .exists() + ) + query = query.filter(subquery) + return query + + @classmethod + def _filter_by_work_type(cls, query, work_type_ids): + if work_type_ids: + query = query.filter(Work.work_type_id.in_(work_type_ids)) + return query + + @classmethod + def _filter_by_project_type(cls, query, project_type_ids): + if project_type_ids: + subquery = ( + Project.query + .filter(Project.type_id.in_(project_type_ids)) + .exists() + ) + query = query.filter(subquery) + return query + + @classmethod + def _filter_by_env_regions(cls, query, env_regions): + if env_regions: + subquery = ( + Project.query + .filter(Project.region_id_env.in_(env_regions)) + .exists() + ) + query = query.filter(subquery) + return query + + @classmethod + def _filter_by_work_states(cls, query, work_states): + if work_states: + query = query.filter(Work.work_state.in_(work_states)) + return query diff --git a/epictrack-api/src/api/resources/__init__.py b/epictrack-api/src/api/resources/__init__.py index 2c099d8e9..64bb6322d 100644 --- a/epictrack-api/src/api/resources/__init__.py +++ b/epictrack-api/src/api/resources/__init__.py @@ -53,6 +53,8 @@ from .work import API as WORK_API from .work_issues import API as WORK_ISSUES_API from .work_status import API as WORK_STATUS_API +from .region import API as REGION_API +from .eao_team import API as EAO_TEAM_API __all__ = ("API_BLUEPRINT", "OPS_BLUEPRINT") @@ -112,3 +114,5 @@ API.add_namespace(WORK_ISSUES_API, path='/work//issues') API.add_namespace(SPECIAL_FIELD_API, path='/special-fields') API.add_namespace(POSITION_API, path='/positions') +API.add_namespace(REGION_API, path='/regions') +API.add_namespace(EAO_TEAM_API, path='/eao-teams') diff --git a/epictrack-api/src/api/resources/eao_team.py b/epictrack-api/src/api/resources/eao_team.py new file mode 100644 index 000000000..7784c0b52 --- /dev/null +++ b/epictrack-api/src/api/resources/eao_team.py @@ -0,0 +1,43 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Resource for Sub Sector endpoints.""" +from http import HTTPStatus + +from flask import jsonify +from flask_restx import Namespace, Resource, cors +from api.schemas.eao_team import EAOTeamSchema + +from api.services.eao_team_service import EAOTeamService +from api.utils import auth, constants, profiletime +from api.utils.caching import AppCache +from api.utils.util import cors_preflight + + +API = Namespace("eao-teams", description="EAOTeams") + + +@cors_preflight("GET") +@API.route("", methods=["GET", "OPTIONS"]) +class EAOTEAM(Resource): + """Endpoint resource to return eao teams""" + + @staticmethod + @cors.crossdomain(origin="*") + @auth.require + @profiletime + @AppCache.cache.cached(timeout=constants.CACHE_DAY_TIMEOUT) + def get(): + """Return all eao teams.""" + eao_teams = EAOTeamService.find_all_teams() + return jsonify(EAOTeamSchema(many=True).dump(eao_teams)), HTTPStatus.OK diff --git a/epictrack-api/src/api/resources/project.py b/epictrack-api/src/api/resources/project.py index d3fa824b2..8da95db3e 100644 --- a/epictrack-api/src/api/resources/project.py +++ b/epictrack-api/src/api/resources/project.py @@ -207,3 +207,21 @@ def post(): request_json.get("name") ) return project_abbreviation, HTTPStatus.CREATED + + +@cors_preflight("GET, DELETE, POST") +@API.route("/types", methods=["GET", "POST", "OPTIONS"]) +class ProjectTypes(Resource): + """Endpoint resource to manage projects.""" + + @staticmethod + @cors.crossdomain(origin="*") + @auth.require + @profiletime + def get(): + """Return all project types.""" + project_types = ProjectService.find_all_project_types() + return ( + jsonify(project_types), + HTTPStatus.OK, + ) diff --git a/epictrack-api/src/api/resources/region.py b/epictrack-api/src/api/resources/region.py new file mode 100644 index 000000000..24e3d4ddd --- /dev/null +++ b/epictrack-api/src/api/resources/region.py @@ -0,0 +1,46 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Resource for Sub Sector endpoints.""" +from http import HTTPStatus + +from flask import jsonify, request +from flask_restx import Namespace, Resource, cors +from api.schemas.region import RegionSchema + +from api.services.region import RegionService +from api.utils import auth, constants, profiletime +from api.utils.caching import AppCache +from api.utils.util import cors_preflight + +from api.schemas import request as req + +API = Namespace("regions", description="Regions") + + +@cors_preflight("GET") +@API.route("", methods=["GET", "OPTIONS"]) +class SubTypes(Resource): + """Endpoint resource to return regions based on region type.""" + + @staticmethod + @cors.crossdomain(origin="*") + @auth.require + @profiletime + @AppCache.cache.cached(timeout=constants.CACHE_DAY_TIMEOUT, query_string=True) + def get(): + """Return all sub_types based on type.""" + req.RegionTypePathParameterSchema().load(request.args) + region_type = request.args.get("type", None) + regions = RegionService.find_regions_by_type(region_type) + return jsonify(RegionSchema(many=True).dump(regions)), HTTPStatus.OK diff --git a/epictrack-api/src/api/resources/work.py b/epictrack-api/src/api/resources/work.py index da41b3b15..b9fd2ad61 100644 --- a/epictrack-api/src/api/resources/work.py +++ b/epictrack-api/src/api/resources/work.py @@ -450,3 +450,18 @@ def get(work_id): args.get("work_indigenous_nation_id"), ) return {"exists": exists}, HTTPStatus.OK + + +@cors_preflight("GET, POST") +@API.route("/types", methods=["GET", "POST", "OPTIONS"]) +class WorkTypes(Resource): + """Endpoint resource to manage works.""" + + @staticmethod + @cors.crossdomain(origin="*") + @auth.require + @profiletime + def get(): + """Return all active works.""" + work_types = WorkService.find_all_work_types() + return jsonify(work_types), HTTPStatus.OK diff --git a/epictrack-api/src/api/schemas/request/__init__.py b/epictrack-api/src/api/schemas/request/__init__.py index a1d43af64..5c391fed5 100644 --- a/epictrack-api/src/api/schemas/request/__init__.py +++ b/epictrack-api/src/api/schemas/request/__init__.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """Exposes all the request validation schemas""" +from api.schemas.request.region_request import RegionTypePathParameterSchema from .act_section_request import ActSectionQueryParameterSchema from .action_configuration_request import ActionConfigurationBodyParameterSchema from .action_template_request import ActionTemplateBodyParameterSchema diff --git a/epictrack-api/src/api/schemas/request/region_request.py b/epictrack-api/src/api/schemas/request/region_request.py new file mode 100644 index 000000000..5bfa9481f --- /dev/null +++ b/epictrack-api/src/api/schemas/request/region_request.py @@ -0,0 +1,26 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Type resource's input validations""" +from marshmallow import fields, validate + +from .base import RequestPathParameterSchema + + +class RegionTypePathParameterSchema(RequestPathParameterSchema): + """Type id path parameter schema""" + + type = fields.Str( + metadata={"description": "The type of the region"}, + validate=validate.Length(min=1), required=True + ) diff --git a/epictrack-api/src/api/services/__init__.py b/epictrack-api/src/api/services/__init__.py index dd08e0d22..a4f0068c3 100644 --- a/epictrack-api/src/api/services/__init__.py +++ b/epictrack-api/src/api/services/__init__.py @@ -39,3 +39,5 @@ from .work_issues import WorkIssuesService from .work_phase import WorkPhaseService from .work_status import WorkStatusService +from .region import RegionService +from .eao_team_service import EAOTeamService diff --git a/epictrack-api/src/api/services/eao_team_service.py b/epictrack-api/src/api/services/eao_team_service.py new file mode 100644 index 000000000..0aae8f811 --- /dev/null +++ b/epictrack-api/src/api/services/eao_team_service.py @@ -0,0 +1,30 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""EAO Team service""" + +from api.models.eao_team import EAOTeam + + +class EAOTeamService: + """Service to manage EAO Team related operations.""" + + @staticmethod + def find_team_by_id(team_id: int): + """Get team by id.""" + return EAOTeam.find_by_id(team_id) + + @staticmethod + def find_all_teams(): + """Get all teams.""" + return EAOTeam.find_all() diff --git a/epictrack-api/src/api/services/project.py b/epictrack-api/src/api/services/project.py index 0e9a6faaa..4b47c6162 100644 --- a/epictrack-api/src/api/services/project.py +++ b/epictrack-api/src/api/services/project.py @@ -30,6 +30,7 @@ from api.models.types import Type from api.models.work import Work from api.models.work_type import WorkType +from api.schemas.types import TypeSchema from api.utils.constants import PROJECT_STATE_ENUM_MAPS from api.utils.enums import ProjectCodeMethod from api.utils.token_info import TokenInfo @@ -318,3 +319,9 @@ def create_project_abbreviation(cls, project_name: str): return project_abbreviation raise BadRequestError("Could not generate a unique project abbreviation") + + @classmethod + def find_all_project_types(cls): + """Get all project types""" + project_types = Type.find_all(default_filters=False) + return TypeSchema(many=True).dump(project_types) diff --git a/epictrack-api/src/api/services/region.py b/epictrack-api/src/api/services/region.py new file mode 100644 index 000000000..c63de7516 --- /dev/null +++ b/epictrack-api/src/api/services/region.py @@ -0,0 +1,30 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Region service""" + +from api.models.region import Region + + +class RegionService: + """Service to manage Region related operations.""" + + @staticmethod + def find_all_regions(): + """Get all regions.""" + return Region.find_all() + + @staticmethod + def find_regions_by_type(region_type: str): + """Get all regions by region type.""" + return Region.find_all_by_region_type(region_type) diff --git a/epictrack-api/src/api/services/work.py b/epictrack-api/src/api/services/work.py index d976531d8..fb2401bfa 100644 --- a/epictrack-api/src/api/services/work.py +++ b/epictrack-api/src/api/services/work.py @@ -53,6 +53,7 @@ from api.models.pagination_options import PaginationOptions from api.models.phase_code import PhaseVisibilityEnum from api.models.work_status import WorkStatus +from api.models.work_type import WorkType from api.schemas.request import ( ActionConfigurationBodyParameterSchema, OutcomeConfigurationBodyParameterSchema, @@ -68,6 +69,7 @@ ) from api.schemas.work_first_nation import WorkFirstNationSchema from api.schemas.work_plan import WorkPlanSchema +from api.schemas.work_type import WorkTypeSchema from api.services.event import EventService from api.services.outcome_configuration import OutcomeConfigurationService from api.services.event_template import EventTemplateService @@ -883,3 +885,9 @@ def create_events_by_configuration( ) ) ) + + @classmethod + def find_all_work_types(cls): + """Get all work types""" + work_types = WorkType.find_all() + return WorkTypeSchema(many=True).dump(work_types) diff --git a/epictrack-api/src/api/utils/enums.py b/epictrack-api/src/api/utils/enums.py index 3a3bfa8bc..4749de7b2 100644 --- a/epictrack-api/src/api/utils/enums.py +++ b/epictrack-api/src/api/utils/enums.py @@ -31,3 +31,10 @@ class ProjectCodeMethod(Enum): METHOD_1 = 1 METHOD_2 = 2 METHOD_3 = 3 + + +class RegionEntityType(Enum): + """Region entity types""" + + ENV = "ENV" + FLNR = "FLNR" diff --git a/epictrack-web/src/components/myWorkplans/Filters/EnvRegionFilter.tsx b/epictrack-web/src/components/myWorkplans/Filters/EnvRegionFilter.tsx new file mode 100644 index 000000000..ce4cb7eea --- /dev/null +++ b/epictrack-web/src/components/myWorkplans/Filters/EnvRegionFilter.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import FilterSelect from "../../shared/filterSelect/FilterSelect"; +import EAOTeamService from "../../../services/eao_team"; +import { OptionType } from "../../shared/filterSelect/type"; + +export const EnvRegionFilter = () => { + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + + const fetchOptions = async () => { + setLoading(true); + try { + const response = await EAOTeamService.getEaoTeams(); + const teams = response.data.map((team) => ({ + label: team.name, + value: team.id.toString(), + })); + setOptions(teams); + setLoading(false); + } catch (error) { + console.log(error); + } + }; + + useEffect(() => { + fetchOptions(); + }, []); + + return ( + { + return; + }} + name="region" + isMulti + info={true} + isLoading={loading} + /> + ); +}; diff --git a/epictrack-web/src/components/myWorkplans/Filters/ProjectTypeFilter.tsx b/epictrack-web/src/components/myWorkplans/Filters/ProjectTypeFilter.tsx new file mode 100644 index 000000000..6bf391719 --- /dev/null +++ b/epictrack-web/src/components/myWorkplans/Filters/ProjectTypeFilter.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import projectService from "../../../services/projectService/projectService"; +import FilterSelect from "../../shared/filterSelect/FilterSelect"; +import { OptionType } from "../../shared/filterSelect/type"; + +export const ProjectTypeFilter = () => { + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + + const fetchOptions = async () => { + setLoading(true); + try { + const response = await projectService.getProjectTypes(); + const types = response.data.map((type) => ({ + label: type.name, + value: type.id.toString(), + })); + setOptions(types); + setLoading(false); + } catch (error) { + console.log(error); + } + }; + + useEffect(() => { + fetchOptions(); + }, []); + + return ( + { + return; + }} + name="projectType" + isMulti + info={true} + isLoading={loading} + /> + ); +}; diff --git a/epictrack-web/src/components/myWorkplans/Filters/TeamFilter.tsx b/epictrack-web/src/components/myWorkplans/Filters/TeamFilter.tsx new file mode 100644 index 000000000..65a9af567 --- /dev/null +++ b/epictrack-web/src/components/myWorkplans/Filters/TeamFilter.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; +import FilterSelect from "../../shared/filterSelect/FilterSelect"; +import EAOTeamService from "../../../services/eao_team"; +import { OptionType } from "../../shared/filterSelect/type"; + +export const TeamFilter = () => { + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + + const fetchOptions = async () => { + setLoading(true); + try { + const response = await EAOTeamService.getEaoTeams(); + const teams = response.data.map((team) => ({ + label: team.name, + value: team.id.toString(), + })); + setOptions(teams); + setLoading(false); + } catch (error) { + console.log(error); + } + }; + + useEffect(() => { + fetchOptions(); + }, []); + + return ( + { + return; + }} + name="team" + isMulti + info={true} + isLoading={loading} + isDisabled={loading} + /> + ); +}; diff --git a/epictrack-web/src/components/myWorkplans/Filters/WorkStateFilter.tsx b/epictrack-web/src/components/myWorkplans/Filters/WorkStateFilter.tsx new file mode 100644 index 000000000..e5db9188c --- /dev/null +++ b/epictrack-web/src/components/myWorkplans/Filters/WorkStateFilter.tsx @@ -0,0 +1,23 @@ +import { WORK_STATE } from "../../shared/constants"; +import FilterSelect from "../../shared/filterSelect/FilterSelect"; + +export const WorkStateFilter = () => { + const options = Object.values(WORK_STATE).map((state) => ({ + label: state, + value: state, + })); + + return ( + { + return; + }} + name="workState" + isMulti + info={true} + /> + ); +}; diff --git a/epictrack-web/src/components/myWorkplans/Filters/WorkType.tsx b/epictrack-web/src/components/myWorkplans/Filters/WorkType.tsx new file mode 100644 index 000000000..2d9db6a98 --- /dev/null +++ b/epictrack-web/src/components/myWorkplans/Filters/WorkType.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import FilterSelect from "../../shared/filterSelect/FilterSelect"; +import { OptionType } from "../../shared/filterSelect/type"; +import workService from "../../../services/workService/workService"; + +export const WorkTypeFilter = () => { + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + + const fetchOptions = async () => { + setLoading(true); + try { + const response = await workService.getWorkTypes(); + const types = response.data.map((type) => ({ + label: type.name, + value: type.id.toString(), + })); + setOptions(types); + setLoading(false); + } catch (error) { + console.log(error); + } + }; + + useEffect(() => { + fetchOptions(); + }, []); + + return ( + { + return; + }} + name="workType" + isMulti + info={true} + isLoading={loading} + /> + ); +}; diff --git a/epictrack-web/src/components/myWorkplans/Filters/index.tsx b/epictrack-web/src/components/myWorkplans/Filters/index.tsx index 5126b6e6e..669b30f0a 100644 --- a/epictrack-web/src/components/myWorkplans/Filters/index.tsx +++ b/epictrack-web/src/components/myWorkplans/Filters/index.tsx @@ -1,7 +1,11 @@ import React from "react"; import { Grid } from "@mui/material"; -import FilterSelect from "../../shared/filterSelect/FilterSelect"; import { NameFilter } from "./NameFilter"; +import { TeamFilter } from "./TeamFilter"; +import { WorkTypeFilter } from "./WorkType"; +import { ProjectTypeFilter } from "./ProjectTypeFilter"; +import { WorkStateFilter } from "./WorkStateFilter"; +import { EnvRegionFilter } from "./EnvRegionFilter"; const Filters = () => { return ( @@ -14,78 +18,21 @@ const Filters = () => { - - - { - return; - }} - name="team" - isMulti - info={true} - /> + + + - - { - return; - }} - name="work_type" - isMulti - info={true} - /> + + - - { - return; - }} - name="project_type" - isMulti - info={true} - /> + + - - { - return; - }} - name="env_region" - isMulti - info={true} - /> + + - - { - return; - }} - name="work_state" - isMulti - info={true} - /> + + diff --git a/epictrack-web/src/components/shared/constants.tsx b/epictrack-web/src/components/shared/constants.tsx new file mode 100644 index 000000000..2fc023307 --- /dev/null +++ b/epictrack-web/src/components/shared/constants.tsx @@ -0,0 +1,8 @@ +export const WORK_STATE = Object.freeze({ + SUSPENDED: "SUSPENDED", + IN_PROGRESS: "IN_PROGRESS", + WITHDRAWN: "WITHDRAWN", + TERMINATED: "TERMINATED", + CLOSED: "CLOSED", + COMPLETED: "COMPLETED", +}); diff --git a/epictrack-web/src/components/shared/filterSelect/FilterSelect.tsx b/epictrack-web/src/components/shared/filterSelect/FilterSelect.tsx index 539cfc839..31df1d61b 100644 --- a/epictrack-web/src/components/shared/filterSelect/FilterSelect.tsx +++ b/epictrack-web/src/components/shared/filterSelect/FilterSelect.tsx @@ -1,254 +1,257 @@ -import React from "react"; -import Select, { SelectInstance } from "react-select"; -import Menu from "./components/Menu"; -import Option from "./components/Option"; -import MultiValue from "./components/MultiValueContainer"; -import { OptionType, SelectProps } from "./type"; -import { Palette } from "../../../styles/theme"; -import SingleValue from "./components/SingleValueContainer"; -import DropdownIndicator from "./components/DropDownIndicator"; -import { MET_Header_Font_Weight_Regular } from "../../../styles/constants"; -import clsx from "clsx"; - -// const useStyle = makeStyles({ -// infoSelect: { -// pointerEvents: "auto", -// borderRadius: "4px", -// "& > div:first-child": { -// paddingRight: 0, -// }, -// "&:hover": { -// backgroundColor: Palette.neutral.bg.main, -// }, -// }, -// }); - -const FilterSelect = (props: SelectProps) => { - // const classes = useStyle(); - const { name, isMulti } = props; - const [options, setOptions] = React.useState([]); - const [selectedOptions, setSelectedOptions] = React.useState(); - const [selectValue, setSelectValue] = React.useState(isMulti ? [] : ""); - const [menuIsOpen, setMenuIsOpen] = React.useState( - !!props.menuIsOpen - ); - const selectRef = React.useRef(null); - - const selectAllOption = React.useMemo( - () => ({ - label: "Select All", - value: "", - }), - [] - ); - - const isSelectAllSelected = () => - selectedOptions.includes(selectAllOption.value); - - const isOptionSelected = (o: OptionType) => - isMulti ? selectedOptions.includes(o.value) : selectedOptions === o.value; - - React.useEffect(() => { - setSelectValue(isMulti ? [] : ""); - }, []); - - React.useEffect(() => { - const currentValues = isMulti - ? selectValue.map((v: OptionType) => v.value) - : selectValue.value; - setSelectedOptions(currentValues); - }, [menuIsOpen]); - - const handleChange = (newValue: any, actionMeta: any) => { - if (!isMulti) { - if (isOptionSelected(newValue)) { - setSelectedOptions(""); - } else { - setSelectedOptions(newValue.value); - } - return; - } - const { option } = actionMeta; - if (option === undefined) return; - - if (option.value === selectAllOption.value) { - if (isSelectAllSelected()) { - setSelectedOptions([]); - } else { - const options = [...(props.options?.map((o: any) => o.value) || [])]; - setSelectedOptions([selectAllOption.value, ...options]); - } - } else { - if (isOptionSelected(option)) { - setSelectedOptions( - selectedOptions.filter( - (o: string) => o !== option.value && o !== selectAllOption.value - ) - ); - } else { - let value = [...selectedOptions, option.value]; - value = Array.from(new Set(value)); - setSelectedOptions(value || []); - } - } - }; - - const applyFilters = () => { - // header.column.setFilterValue(selectedOptions); - if (props.filterAppliedCallback) { - props.filterAppliedCallback(selectedOptions); - } - if (selectedOptions.length === 0) { - selectRef.current?.clearValue(); - } - if (isMulti) { - const value = options.filter((o: OptionType) => - selectedOptions.includes(o.value) - ); - setSelectValue(value); - } else { - const value = options.find( - (o: OptionType) => o.value === selectedOptions - ); - setSelectValue(value); - } - setMenuIsOpen(false); - selectRef.current?.blur(); - }; - - const clearFilters = () => { - setSelectedOptions([]); - setSelectValue(isMulti ? [] : ""); - // header.column.setFilterValue(isMulti ? [] : ""); - if (props.filterClearedCallback) { - props.filterClearedCallback(isMulti ? [] : ""); - } - selectRef.current?.clearValue(); - }; - - const onCancel = () => { - const currentValues = isMulti - ? selectValue.map((v: OptionType) => v.value) - : selectValue.value; - setSelectedOptions(currentValues || isMulti ? [] : ""); - setMenuIsOpen(false); - selectRef.current?.blur(); - }; - - React.useEffect(() => { - let filterOptions = props.options as OptionType[]; - if (isMulti) filterOptions = [selectAllOption, ...filterOptions]; - setOptions(filterOptions); - }, [props.options]); - - return ( - null, + DropdownIndicator, + }} + filterProps={{ + applyFilters, + clearFilters, + selectedOptions, + onCancel, + variant: props.variant || "inline", + }} + menuIsOpen={menuIsOpen} + closeMenuOnSelect={false} + hideSelectedOptions={false} + onFocus={() => setMenuIsOpen(true)} + onBlur={() => setMenuIsOpen(false)} + ref={selectRef} + styles={{ + option: (base, props) => ({ + ...base, + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + display: "flex", + alignItems: "center", + padding: ".5rem .75rem .5rem 0px", + fontWeight: "normal", + fontSize: "1rem", + background: props.isFocused ? Palette.neutral.bg.main : "transparent", + color: props.isSelected + ? Palette.primary.accent.main + : Palette.neutral.accent.dark, + cursor: props.isFocused ? "pointer" : "default", + }), + control: (base, props) => ({ + ...base, + background: props.hasValue ? Palette.primary.bg.light : Palette.white, + borderWidth: "2px", + borderStyle: props.hasValue ? "none" : "solid", + borderColor: + props.isFocused || props.menuIsOpen + ? Palette.primary.accent.light + : Palette.neutral.accent.light, + boxShadow: "none", + // "&:hover": { + // borderColor: + // props.isFocused || props.menuIsOpen + // ? Palette.primary.accent.light + // : "transparent", + // }, + ...(props.selectProps.filterProps?.variant === "bar" && { + borderColor: props.isFocused + ? Palette.primary.accent.light + : "transparent", + }), + }), + menu: (base, props) => ({ + ...base, + position: "relative", + marginBlock: "0px", + border: `1px solid ${Palette.neutral.accent.light}`, + borderRadius: "4px", + }), + placeholder: (base, props) => ({ + ...base, + fontWeight: MET_Header_Font_Weight_Regular, + color: Palette.neutral.light, + fontSize: "0.875rem", + lineHeight: "1rem", + ...(props.selectProps.filterProps?.variant == "bar" && { + color: Palette.primary.accent.main, + fontWeight: 700, + }), + }), + menuPortal: (base, props) => ({ + ...base, + zIndex: 2, + marginTop: "4px", + }), + input: (base, props) => ({ + ...base, + fontWeight: "400", + }), + }} + isClearable={false} + menuPortalTarget={document.body} + controlShouldRenderValue={props.controlShouldRenderValue} + // className={clsx({ + // [classes.infoSelect]: props.info, + // })} + // classNames={{ + // control: () => (props.info ? classes.infoSelect : ""), + // }} + isLoading={props.isLoading} + loadingMessage={() => "Loading..."} + isDisabled={props.isDisabled} + /> + ); +}; + +export default FilterSelect; diff --git a/epictrack-web/src/constants/api-endpoint.ts b/epictrack-web/src/constants/api-endpoint.ts index 919e2be12..04be6adfd 100644 --- a/epictrack-web/src/constants/api-endpoint.ts +++ b/epictrack-web/src/constants/api-endpoint.ts @@ -9,6 +9,7 @@ const Endpoints = { }, Projects: { PROJECTS: "projects", + PROJECT_TYPES: "projects/types", WORK_TYPES: "projects/:project_id/work-types", FIRST_NATIONS: "projects/:project_id/first-nations", FIRST_NATION_AVAILABLE: "projects/:project_id/first-nation-available", @@ -36,6 +37,7 @@ const Endpoints = { DOWNLOAD_WORK_FIRST_NATIONS: "works/:work_id/first-nations/download", WORK_FIRST_NATION: "works/first-nations/:work_first_nation_id", WORK_IMPORT_FIRST_NATIONS: "works/:work_id/first-nations/import", + GET_ALL_WORK_TYPES: "works/types", }, WorkIssues: { ISSUES: "work/:work_id/issues", @@ -83,6 +85,12 @@ const Endpoints = { Workplan: { GET_ALL: "/works/dashboard", }, + EAO_TEAMS: { + GET_ALL: "/eao-teams", + }, + REGION: { + GET_ALL: "/regions", + }, Position: { GET_ALL: "positions", }, diff --git a/epictrack-web/src/services/eao_team/index.tsx b/epictrack-web/src/services/eao_team/index.tsx new file mode 100644 index 000000000..3b8173bfb --- /dev/null +++ b/epictrack-web/src/services/eao_team/index.tsx @@ -0,0 +1,13 @@ +import Endpoints from "../../constants/api-endpoint"; +import http from "../../apiManager/http-request-handler"; +import { ListType } from "../../models/code"; + +const getEaoTeams = async () => { + return await http.GetRequest(Endpoints.EAO_TEAMS.GET_ALL); +}; + +const EAOTeamService = { + getEaoTeams, +}; + +export default EAOTeamService; diff --git a/epictrack-web/src/services/projectService/projectService.ts b/epictrack-web/src/services/projectService/projectService.ts index 6adbf8a80..45d28a5f6 100644 --- a/epictrack-web/src/services/projectService/projectService.ts +++ b/epictrack-web/src/services/projectService/projectService.ts @@ -2,6 +2,7 @@ import Endpoints from "../../constants/api-endpoint"; import http from "../../apiManager/http-request-handler"; import ServiceBase from "../common/serviceBase"; import { MasterBase } from "../../models/type"; +import { ListType } from "../../models/code"; class ProjectService implements ServiceBase { async getAll(return_type?: string) { @@ -73,6 +74,10 @@ class ProjectService implements ServiceBase { }) ); } + + async getProjectTypes() { + return await http.GetRequest(Endpoints.Projects.PROJECT_TYPES); + } } export default new ProjectService(); diff --git a/epictrack-web/src/services/regionService/index.tsx b/epictrack-web/src/services/regionService/index.tsx new file mode 100644 index 000000000..04406570e --- /dev/null +++ b/epictrack-web/src/services/regionService/index.tsx @@ -0,0 +1,12 @@ +import Endpoints from "../../constants/api-endpoint"; +import http from "../../apiManager/http-request-handler"; + +const getRegions = async () => { + return await http.GetRequest(Endpoints.REGION.GET_ALL); +}; + +const codeService = { + getCodes: getRegions, +}; + +export default codeService; diff --git a/epictrack-web/src/services/workService/workService.ts b/epictrack-web/src/services/workService/workService.ts index 42c89bcb2..8499d411b 100644 --- a/epictrack-web/src/services/workService/workService.ts +++ b/epictrack-web/src/services/workService/workService.ts @@ -5,6 +5,8 @@ import { MasterBase } from "../../models/type"; import { StaffWorkRole } from "../../models/staff"; import { WorkFirstNation } from "../../models/firstNation"; import { Work } from "../../models/work"; +import { ListType } from "../../models/code"; +import { WorkType } from "../../models/workType"; class WorkService implements ServiceBase { async getAll() { @@ -203,5 +205,11 @@ class WorkService implements ServiceBase { work_indigenous_nation_id, }); } + + async getWorkTypes() { + return await http.GetRequest( + Endpoints.Works.GET_ALL_WORK_TYPES + ); + } } export default new WorkService();