diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 6f4a935b..502ae5c2 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -23,6 +23,8 @@ jobs: PLATFORMS: "linux/amd64,linux/arm64" permissions: pull-requests: write # Required to post comments + contents: read + checks: write steps: - name: Checkout code uses: actions/checkout@v4 @@ -177,6 +179,8 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write + contents: read + checks: write steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 317ccbc1..75fec095 100755 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -155,7 +155,9 @@ services: image: crapi/crapi-web:${VERSION:-latest} ports: - "${LISTEN_IP:-127.0.0.1}:8888:80" + - "${LISTEN_IP:-127.0.0.1}:30080:80" - "${LISTEN_IP:-127.0.0.1}:8443:443" + - "${LISTEN_IP:-127.0.0.1}:30443:443" environment: - COMMUNITY_SERVICE=crapi-community:${COMMUNITY_SERVER_PORT:-8087} - IDENTITY_SERVICE=crapi-identity:${IDENTITY_SERVER_PORT:-8080} diff --git a/postman_collections/crAPI.postman_environment.json b/postman_collections/crAPI.postman_environment.json index 617c9922..c61eff32 100644 --- a/postman_collections/crAPI.postman_environment.json +++ b/postman_collections/crAPI.postman_environment.json @@ -3,12 +3,12 @@ "name": "Crapi", "values": [{ "key": "url", - "value": "http://127.0.0.1:8888", + "value": "http://127.0.0.1:30080", "enabled": true }, { "key": "url_mail", - "value": "http://127.0.0.1:8025", + "value": "http://127.0.0.1:30080/mailhog", "enabled": true } ], diff --git a/services/identity/src/main/java/com/crapi/constant/UserMessage.java b/services/identity/src/main/java/com/crapi/constant/UserMessage.java index 4ee3aeba..f1eead17 100644 --- a/services/identity/src/main/java/com/crapi/constant/UserMessage.java +++ b/services/identity/src/main/java/com/crapi/constant/UserMessage.java @@ -91,7 +91,7 @@ public class UserMessage { public static final String CONVERT_VIDEO_INTERNAL_ERROR = "Error occured while executing."; public static final String CONVERT_VIDEO_CLOSE_TO_WIN_THE_GAME = "You are very close."; public static final String CONVERT_VIDEO_BASH_COMMAND_TRIGGERED = - "Video conversion bash command triggered."; + "Video conversion command executed."; public static final String SORRY_DIDNT_GET_PROFILE = "Sorry, Didn't get any profile video name for the user."; public static final String THIS_IS_ADMIN_FUNCTION = diff --git a/services/identity/src/main/java/com/crapi/service/Impl/ProfileServiceImpl.java b/services/identity/src/main/java/com/crapi/service/Impl/ProfileServiceImpl.java index 5f23d5cd..8a7544e9 100644 --- a/services/identity/src/main/java/com/crapi/service/Impl/ProfileServiceImpl.java +++ b/services/identity/src/main/java/com/crapi/service/Impl/ProfileServiceImpl.java @@ -202,7 +202,7 @@ public CRAPIResponse deleteAdminProfileVideo(Long videoId, HttpServletRequest re @Transactional @Override public CRAPIResponse convertVideo(Long videoId, HttpServletRequest request) { - BashCommand bashCommand = new BashCommand(); + BashCommand conversionShell = new BashCommand(); ProfileVideo profileVideo; String host = request.getHeader(HttpHeaders.HOST); String xForwardedHost = request.getHeader("x-forwarded-host"); @@ -237,8 +237,11 @@ public CRAPIResponse convertVideo(Long videoId, HttpServletRequest request) { && enable_shell_injection && optionalProfileVideo.get().getConversion_params() != null) { profileVideo = optionalProfileVideo.get(); - return new CRAPIResponse( - bashCommand.executeBashCommand(profileVideo.getConversion_params()), 200); + String conversionCommand = + String.format( + "convertVideo -i %s %s", + profileVideo.getVideo_name(), profileVideo.getConversion_params()); + return new CRAPIResponse(conversionShell.executeBashCommand(conversionCommand), 200); } return new CRAPIResponse(UserMessage.CONVERT_VIDEO_INTERNAL_ERROR, 500); } diff --git a/services/mailhog/Dockerfile b/services/mailhog/Dockerfile index 2214375c..79b7b2bf 100644 --- a/services/mailhog/Dockerfile +++ b/services/mailhog/Dockerfile @@ -1,7 +1,6 @@ # # MailHog Dockerfile # - FROM golang:alpine AS builder # Install MailHog: diff --git a/services/web/package.json b/services/web/package.json index 30509249..97f835f9 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -1,7 +1,7 @@ { "name": "crapi-web", "version": "0.1.0", - "proxy": "http://localhost:8888", + "proxy": "http://localhost:30080", "private": true, "dependencies": { "@ant-design/cssinjs": "^1.21.1", diff --git a/services/web/src/actions/mechanicActions.ts b/services/web/src/actions/mechanicActions.ts new file mode 100644 index 00000000..6399bd62 --- /dev/null +++ b/services/web/src/actions/mechanicActions.ts @@ -0,0 +1,33 @@ +import actionTypes from "../constants/actionTypes"; + +interface ActionPayload { + accessToken: string; + callback: (res: any, data?: any) => void; + [key: string]: any; +} + +export const createCommentAction = ({ + accessToken, + serviceId, + comment, + callback, + ...data +}: ActionPayload) => { + return { + type: actionTypes.CREATE_SERVICE_COMMENT, + payload: { accessToken, serviceId, comment, ...data, callback }, + }; +}; + +export const updateServiceRequestStatusAction = ({ + accessToken, + serviceId, + status, + callback, + ...data +}: ActionPayload) => { + return { + type: actionTypes.UPDATE_SERVICE_REQUEST_STATUS, + payload: { accessToken, serviceId, status, ...data, callback }, + }; +}; diff --git a/services/web/src/actions/profileActions.ts b/services/web/src/actions/profileActions.ts index b231e5b8..2c2b40ad 100644 --- a/services/web/src/actions/profileActions.ts +++ b/services/web/src/actions/profileActions.ts @@ -17,7 +17,7 @@ import actionTypes from "../constants/actionTypes"; interface ActionPayload { accessToken: string; - callback: () => void; + callback: (status: string, data: any) => void; [key: string]: any; } @@ -69,7 +69,7 @@ export const changeVideoNameAction = ({ interface ConvertVideoPayload { accessToken: string; videoId: string; - callback: () => void; + callback: (res: string, data: any) => void; } export const convertVideoAction = ({ @@ -86,3 +86,18 @@ export const convertVideoAction = ({ }, }; }; + +export const getVideoAction = ({ + accessToken, + videoId, + callback, +}: ActionPayload) => { + return { + type: actionTypes.GET_VIDEO, + payload: { + accessToken, + videoId, + callback, + }, + }; +}; diff --git a/services/web/src/actions/userActions.ts b/services/web/src/actions/userActions.ts index 82a0cf90..ef5c1003 100644 --- a/services/web/src/actions/userActions.ts +++ b/services/web/src/actions/userActions.ts @@ -187,6 +187,18 @@ export const getMechanicServicesAction = ({ }; }; +export const getMechanicServiceAction = ({ + accessToken, + serviceId, + callback, + ...data +}: ActionPayload & AccessTokenPayload) => { + return { + type: actionTypes.GET_MECHANIC_SERVICE, + payload: { accessToken, serviceId, callback, ...data }, + }; +}; + export const getVehicleServicesAction = ({ accessToken, VIN, diff --git a/services/web/src/actions/vehicleActions.ts b/services/web/src/actions/vehicleActions.ts index 371408b5..6392eef4 100644 --- a/services/web/src/actions/vehicleActions.ts +++ b/services/web/src/actions/vehicleActions.ts @@ -17,7 +17,7 @@ import actionTypes from "../constants/actionTypes"; interface ActionPayload { accessToken: string; - callback: () => void; + callback: (res: string, data: any) => void; [key: string]: any; } @@ -41,8 +41,8 @@ export const verifyVehicleAction = ({ }; export const getMechanicsAction = ({ - callback, accessToken, + callback, ...data }: ActionPayload) => { return { diff --git a/services/web/src/components/layout/layout.tsx b/services/web/src/components/layout/layout.tsx index f807c7db..836864c1 100644 --- a/services/web/src/components/layout/layout.tsx +++ b/services/web/src/components/layout/layout.tsx @@ -41,6 +41,7 @@ import NewPostContainer from "../../containers/newPost/newPost"; import PostContainer from "../../containers/post/post"; import VehicleServiceDashboardContainer from "../../containers/vehicleServiceDashboard/vehicleServiceDashboard"; import ServiceReportContiner from "../../containers/serviceReport/serviceReport"; +import MechanicServiceRequestContainer from "../../containers/mechanicServiceRequest/mechanicServiceRequest"; import { logOutUserAction, validateAccessTokenAction, @@ -241,6 +242,19 @@ const StyledComp: React.FC = (props) => { /> } /> + + } + /> = ({ services }) => { {services.map((service) => ( - + +

+ Problem Details: + {service.problem_details} +

+

+ Vehicle VIN: + {service.vehicle.vin} +

Owner email-id: {service.vehicle.owner.email} @@ -59,6 +68,17 @@ const MechanicDashboard: React.FC = ({ services }) => { Owner Phone No.: {service.vehicle.owner.number}

+

+ Status: + {service.status} +

+

+ Updated On: + {service.updated_on} +

+

+ View Service +

))} diff --git a/services/web/src/components/mechanicServiceRequest/mechanicServiceRequest.tsx b/services/web/src/components/mechanicServiceRequest/mechanicServiceRequest.tsx new file mode 100644 index 00000000..a84267dd --- /dev/null +++ b/services/web/src/components/mechanicServiceRequest/mechanicServiceRequest.tsx @@ -0,0 +1,312 @@ +/* + * + * 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. + */ + +import React, { useEffect, useState } from "react"; +import { + Card, + Row, + Col, + Descriptions, + Spin, + Layout, + Timeline, + Input, + Button, + Form, + Select, + Modal, + Space, +} from "antd"; +import { PageHeader } from "@ant-design/pro-components"; +import { Content } from "antd/es/layout/layout"; +import { connect, ConnectedProps } from "react-redux"; +import responseTypes from "../../constants/responseTypes"; +import { RootState } from "../../reducers/rootReducer"; +import { + createCommentAction, + updateServiceRequestStatusAction, +} from "../../actions/mechanicActions"; +import { getMechanicServiceAction } from "../../actions/userActions"; +import { FAILURE_MESSAGE } from "../../constants/messages"; + +interface Owner { + email: string; + number: string; +} + +interface Vehicle { + owner: Owner; + id: string; + vin: string; +} + +interface Mechanic { + mechanic_code: string; + user: Owner; +} + +interface Comment { + comment: string; + created_on: string; +} + +interface Service { + id: string; + problem_details: string; + created_on: string; + vehicle: Vehicle; + status: string; + mechanic: Mechanic; + comments: Comment[]; +} + +interface MechanicServiceRequestProps { + serviceId: string; +} + +type PropsFromRedux = ConnectedProps & + MechanicServiceRequestProps; + +const MechanicServiceRequest: React.FC = (props) => { + const { + accessToken, + serviceId, + getService, + createComment, + updateServiceRequestStatus, + } = props; + + const [service, setService] = useState(); + + useEffect(() => { + const callback = (res: string, data: Service) => { + if (res === responseTypes.SUCCESS) { + console.log("Data", data); + console.log("res", res); + setService(data as Service); + } else { + Modal.error({ + title: FAILURE_MESSAGE, + content: "Failed to fetch service request", + }); + } + }; + getService({ accessToken, serviceId, callback }); + }, [accessToken, serviceId, getService]); + + const [form] = Form.useForm(); + function handleAddComment(): void { + const comment = form.getFieldValue("comment"); + console.log("Comment", comment); + createComment({ + accessToken: accessToken || "", + serviceId: service?.id || "", + comment: comment, + callback: (status: any, data?: Comment) => { + if (status === responseTypes.SUCCESS && data) { + if (service) { + setService({ + ...service, + comments: [data, ...(service?.comments || [])], + }); + form.resetFields(["comment"]); + } + } else { + Modal.error({ + title: "Error", + content: "Error adding comment", + }); + } + }, + }); + } + + function handleStatusChange(value: string): void { + console.log("Status changed to", value); + updateServiceRequestStatus({ + accessToken: accessToken || "", + serviceId: service?.id || "", + status: value, + callback: (status: any, data?: any) => { + if (status === responseTypes.SUCCESS && data) { + setService(data as Service); + } + }, + }); + } + + if (!service) { + console.log("Service is undefined"); + return ( + + + + ); + } + + console.log("Comments ", service?.comments); + + return ( + + + + + + + + + {service.id} + + + = (props) => { hidden ref={videoInputRef} accept="video/*" - onChange={uploadVideo} + onChange={handleUploadVideo} /> - {profileData.videoData && ( + {videoData && ( @@ -227,7 +365,7 @@ const Profile: React.FC = (props) => { initialValues={{ remember: true, }} - onFinish={onVideoFormFinish} + onFinish={handleChangeVideoName} > = (props) => { ); }; +const mapDispatchToProps = { + uploadProfilePic: uploadProfilePicAction, + uploadVideo: uploadVideoAction, + changeVideoName: changeVideoNameAction, + convertVideo: convertVideoAction, + getVideo: getVideoAction, +}; + const mapStateToProps = ({ userReducer, profileReducer, @@ -258,4 +404,6 @@ const mapStateToProps = ({ return { userData: userReducer, profileData: profileReducer }; }; -export default connect(mapStateToProps)(Profile); +const connector = connect(mapStateToProps, mapDispatchToProps); + +export default connector(Profile); diff --git a/services/web/src/components/serviceReport/serviceReport.tsx b/services/web/src/components/serviceReport/serviceReport.tsx index 476ad889..6e6c579f 100644 --- a/services/web/src/components/serviceReport/serviceReport.tsx +++ b/services/web/src/components/serviceReport/serviceReport.tsx @@ -14,7 +14,7 @@ */ import React from "react"; -import { Card, Row, Col, Descriptions, Spin, Layout } from "antd"; +import { Card, Row, Col, Descriptions, Spin, Layout, Timeline } from "antd"; import { PageHeader } from "@ant-design/pro-components"; import { Content } from "antd/es/layout/layout"; @@ -41,6 +41,10 @@ interface Service { vehicle: Vehicle; status: string; mechanic: Mechanic; + comments: { + comment: string; + created_on: string; + }[]; } interface ServiceReportProps { @@ -99,6 +103,34 @@ const ServiceReport: React.FC = ({ service }) => { + + + + + + + + ({ + label: comment.created_on, + children: comment.comment, + color: index === 0 ? "green" : "gray", + }))} + > + + + + + diff --git a/services/web/src/constants/APIConstant.ts b/services/web/src/constants/APIConstant.ts index 45479de8..1e42ff61 100644 --- a/services/web/src/constants/APIConstant.ts +++ b/services/web/src/constants/APIConstant.ts @@ -49,6 +49,7 @@ export const requestURLS: RequestURLSType = { VERIFY_TOKEN: "api/v2/user/verify-email-token", UPLOAD_PROFILE_PIC: "api/v2/user/pictures", UPLOAD_VIDEO: "api/v2/user/videos", + GET_VIDEO: "api/v2/user/videos/", CHANGE_VIDEO_NAME: "api/v2/user/videos/", REFRESH_LOCATION: "api/v2/vehicle//location", CONVERT_VIDEO: "api/v2/user/videos/convert_video", @@ -57,6 +58,9 @@ export const requestURLS: RequestURLSType = { GET_MECHANICS: "api/mechanic", GET_PRODUCTS: "api/shop/products", GET_MECHANIC_SERVICES: "api/mechanic/service_requests", + GET_MECHANIC_SERVICE: "api/mechanic/service_request/", + CREATE_SERVICE_COMMENT: "api/mechanic/service_request//comment", + UPDATE_SERVICE_REQUEST_STATUS: "api/mechanic/service_request/", GET_VEHICLE_SERVICES: "api/merchant/service_requests/", GET_SERVICE_REPORT: "api/mechanic/mechanic_report", BUY_PRODUCT: "api/shop/orders", diff --git a/services/web/src/constants/actionTypes.ts b/services/web/src/constants/actionTypes.ts index 5bde3898..861a3b60 100644 --- a/services/web/src/constants/actionTypes.ts +++ b/services/web/src/constants/actionTypes.ts @@ -36,6 +36,9 @@ const actionTypes = { LOG_OUT: "LOG_OUT", INVALID_SESSION: "INVALID_SESSION", GET_MECHANIC_SERVICES: "GET_MECHANIC_SERVICES", + GET_MECHANIC_SERVICE: "GET_MECHANIC_SERVICE", + CREATE_SERVICE_COMMENT: "CREATE_SERVICE_COMMENT", + UPDATE_SERVICE_REQUEST_STATUS: "UPDATE_SERVICE_REQUEST_STATUS", GET_VEHICLE_SERVICES: "GET_VEHICLE_SERVICES", GET_SERVICE_REPORT: "GET_SERVICE_REPORT", RESEND_MAIL: "RESEND_MAIL", @@ -55,7 +58,7 @@ const actionTypes = { CHANGE_VIDEO_NAME: "CHANGE_VIDEO_NAME", VIDEO_NAME_CHANGED: "VIDEO_NAME_CHANGED", CONVERT_VIDEO: "CONVERT_VIDEO", - + GET_VIDEO: "GET_VIDEO", BALANCE_CHANGED: "BALANCE_CHANGED", GET_PRODUCTS: "GET_PRODUCTS", FETCHED_PRODUCTS: "FETCHED_PRODUCTS", diff --git a/services/web/src/containers/contactMechanic/contactMechanic.js b/services/web/src/containers/contactMechanic/contactMechanic.tsx similarity index 70% rename from services/web/src/containers/contactMechanic/contactMechanic.js rename to services/web/src/containers/contactMechanic/contactMechanic.tsx index 31ba3d7c..f6ab3101 100644 --- a/services/web/src/containers/contactMechanic/contactMechanic.js +++ b/services/web/src/containers/contactMechanic/contactMechanic.tsx @@ -15,8 +15,7 @@ import React, { useEffect } from "react"; -import PropTypes from "prop-types"; -import { connect } from "react-redux"; +import { connect, ConnectedProps } from "react-redux"; import { Modal } from "antd"; import ContactMechanic from "../../components/contactMechanic/contactMechanic"; import { useNavigate } from "react-router-dom"; @@ -26,27 +25,29 @@ import { } from "../../actions/vehicleActions"; import responseTypes from "../../constants/responseTypes"; import { SUCCESS_MESSAGE } from "../../constants/messages"; +import { RootState } from "../../reducers/rootReducer"; -const ContactMechanicContainer = (props) => { - const { accessToken, getMechanics } = props; - const navigate = useNavigate(); +type PropsFromRedux = ConnectedProps; +const ContactMechanicContainer: React.FC = (props) => { + const navigate = useNavigate(); + const { accessToken, getMechanics, contactMechanic } = props; const [hasErrored, setHasErrored] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(""); - useEffect(() => { - const callback = (res, data) => { - if (res !== responseTypes.SUCCESS) { + const callback = (status: string, data: any) => { + if (status !== responseTypes.SUCCESS) { setHasErrored(true); setErrorMessage(data); + return; } }; - getMechanics({ callback, accessToken }); + getMechanics({ accessToken, callback }); }, [accessToken, getMechanics]); - const onFinish = (values) => { - const callback = (res, data) => { - if (res === responseTypes.SUCCESS) { + const onFinish = (values: any) => { + const callback = (status: string, data: any) => { + if (status === responseTypes.SUCCESS) { Modal.success({ title: SUCCESS_MESSAGE, content: data, @@ -73,7 +74,7 @@ const ContactMechanicContainer = (props) => { ); }; -const mapStateToProps = ({ userReducer: { accessToken } }) => { +const mapStateToProps = ({ userReducer: { accessToken } }: RootState) => { return { accessToken }; }; @@ -82,13 +83,6 @@ const mapDispatchToProps = { contactMechanic: contactMechanicAction, }; -ContactMechanicContainer.propTypes = { - accessToken: PropTypes.string, - getMechanics: PropTypes.func, - contactMechanic: PropTypes.func, -}; +const connector = connect(mapStateToProps, mapDispatchToProps); -export default connect( - mapStateToProps, - mapDispatchToProps, -)(ContactMechanicContainer); +export default connector(ContactMechanicContainer); diff --git a/services/web/src/containers/mechanicDashboard/mechanicDashboard.tsx b/services/web/src/containers/mechanicDashboard/mechanicDashboard.tsx index 5a09a2b3..b2b003dc 100644 --- a/services/web/src/containers/mechanicDashboard/mechanicDashboard.tsx +++ b/services/web/src/containers/mechanicDashboard/mechanicDashboard.tsx @@ -32,12 +32,16 @@ interface Service { id: string; problem_details: string; created_on: string; + updated_on: string; vehicle: { + id: string; + vin: string; owner: { email: string; number: string; }; }; + status: string; } const mapStateToProps = (state: RootState) => ({ diff --git a/services/web/src/containers/mechanicServiceRequest/mechanicServiceRequest.tsx b/services/web/src/containers/mechanicServiceRequest/mechanicServiceRequest.tsx new file mode 100644 index 00000000..181ea0c5 --- /dev/null +++ b/services/web/src/containers/mechanicServiceRequest/mechanicServiceRequest.tsx @@ -0,0 +1,50 @@ +/* + * + * 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. + */ + +import React from "react"; + +import { connect, ConnectedProps } from "react-redux"; +import { getMechanicServiceAction } from "../../actions/userActions"; +import MechanicServiceRequest from "../../components/mechanicServiceRequest/mechanicServiceRequest"; + +interface RootState { + service: any; + userReducer: { + accessToken: string; + }; +} + +const mapStateToProps = (state: RootState) => ({ + accessToken: state.userReducer.accessToken, + service: state.service, +}); + +const mapDispatchToProps = { + getService: getMechanicServiceAction, +}; + +type PropsFromRedux = ConnectedProps; + +const MechanicServiceRequestContainer: React.FC = () => { + const urlParams = new URLSearchParams(window.location.search); + const serviceId = urlParams.get("id") || ""; + console.log("Service ID", serviceId); + + return ; +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +export default connector(MechanicServiceRequestContainer); diff --git a/services/web/src/containers/profile/profile.js b/services/web/src/containers/profile/profile.js deleted file mode 100644 index 908909ba..00000000 --- a/services/web/src/containers/profile/profile.js +++ /dev/null @@ -1,147 +0,0 @@ -/* - * - * 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. - */ - -import React, { useState } from "react"; - -import PropTypes from "prop-types"; -import { connect } from "react-redux"; -import { Modal } from "antd"; -import Profile from "../../components/profile/profile"; -import { - uploadProfilePicAction, - uploadVideoAction, - changeVideoNameAction, - convertVideoAction, -} from "../../actions/profileActions"; -import responseTypes from "../../constants/responseTypes"; -import { SUCCESS_MESSAGE, FAILURE_MESSAGE } from "../../constants/messages"; - -const ProfileContainer = (props) => { - const { - accessToken, - videoId, - uploadProfilePic, - uploadVideo, - changeVideoName, - convertVideo, - } = props; - - const [isVideoModalOpen, setIsVideoModalOpen] = useState(false); - const [hasErrored, setHasErrored] = React.useState(false); - const [errorMessage, setErrorMessage] = React.useState(""); - - const handleUploadProfilePic = (event) => { - const callback = (res, data) => { - if (res === responseTypes.SUCCESS) { - Modal.success({ - title: SUCCESS_MESSAGE, - content: data, - }); - } else { - Modal.error({ - title: FAILURE_MESSAGE, - content: data, - }); - } - }; - uploadProfilePic({ callback, accessToken, file: event.target.files[0] }); - }; - - const handleUploadVideo = (event) => { - const callback = (res, data) => { - if (res === responseTypes.SUCCESS) { - Modal.success({ - title: SUCCESS_MESSAGE, - content: data, - }); - } else { - Modal.error({ - title: FAILURE_MESSAGE, - content: data, - }); - } - }; - uploadVideo({ callback, accessToken, file: event.target.files[0] }); - }; - - const handleChangeVideoName = (values) => { - const callback = (res, data) => { - if (res === responseTypes.SUCCESS) { - setIsVideoModalOpen(false); - Modal.success({ - title: SUCCESS_MESSAGE, - content: data, - }); - } else { - setHasErrored(true); - setErrorMessage(data); - } - }; - changeVideoName({ - callback, - accessToken, - videoId, - ...values, - }); - }; - - const shareVideoWithCommunity = () => { - const callback = (res, data) => { - Modal.error({ - title: FAILURE_MESSAGE, - content: data, - }); - }; - convertVideo({ callback, accessToken, videoId }); - }; - - return ( - - ); -}; - -const mapStateToProps = ({ - userReducer: { accessToken }, - profileReducer: { videoId }, -}) => { - return { accessToken, videoId }; -}; - -const mapDispatchToProps = { - uploadProfilePic: uploadProfilePicAction, - uploadVideo: uploadVideoAction, - changeVideoName: changeVideoNameAction, - convertVideo: convertVideoAction, -}; - -ProfileContainer.propTypes = { - accessToken: PropTypes.string, - videoId: PropTypes.number, - uploadProfilePic: PropTypes.func, - uploadVideo: PropTypes.func, - changeVideoName: PropTypes.func, - convertVideo: PropTypes.func, -}; - -export default connect(mapStateToProps, mapDispatchToProps)(ProfileContainer); diff --git a/services/web/src/containers/profile/profile.tsx b/services/web/src/containers/profile/profile.tsx new file mode 100644 index 00000000..0de05f43 --- /dev/null +++ b/services/web/src/containers/profile/profile.tsx @@ -0,0 +1,32 @@ +/* + * + * 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. + */ + +import React from "react"; +import { connect, ConnectedProps } from "react-redux"; +import Profile from "../../components/profile/profile"; + +interface RootState {} + +const mapStateToProps = (state: RootState) => ({}); + +const connector = connect(mapStateToProps); + +type PropsFromRedux = ConnectedProps; + +const ProfileContainer: React.FC = () => { + return ; +}; + +export default connector(ProfileContainer); diff --git a/services/web/src/containers/serviceReport/serviceReport.tsx b/services/web/src/containers/serviceReport/serviceReport.tsx index de7ddc27..c493fba9 100644 --- a/services/web/src/containers/serviceReport/serviceReport.tsx +++ b/services/web/src/containers/serviceReport/serviceReport.tsx @@ -48,6 +48,10 @@ interface Service { number: string; }; }; + comments: { + comment: string; + created_on: string; + }[]; } const mapStateToProps = (state: RootState) => ({ @@ -83,6 +87,7 @@ const ServiceReportContainer: React.FC = ({ }); } }; + console.log("getServiceReport", accessToken, reportId, callback); getServiceReport({ accessToken, reportId, callback }); }, [accessToken, getServiceReport, reportId]); diff --git a/services/web/src/containers/vehicleServiceDashboard/vehicleServiceDashboard.tsx b/services/web/src/containers/vehicleServiceDashboard/vehicleServiceDashboard.tsx index f03c91d6..79997520 100644 --- a/services/web/src/containers/vehicleServiceDashboard/vehicleServiceDashboard.tsx +++ b/services/web/src/containers/vehicleServiceDashboard/vehicleServiceDashboard.tsx @@ -65,9 +65,9 @@ const VehicleServiceDashboardContainer: React.FC = ({ console.log("VIN", VIN); useEffect(() => { - const callback = (res: string, data: Service[] | string) => { - console.log("Callback", res, data); - if (res === responseTypes.SUCCESS) { + const callback = (status: string, data: Service[] | string) => { + console.log("Callback", status, data); + if (status === responseTypes.SUCCESS) { setServices(data as Service[]); } else { Modal.error({ @@ -76,6 +76,7 @@ const VehicleServiceDashboardContainer: React.FC = ({ }); } }; + console.log("getVehicleServiceHistory", accessToken, VIN, callback); getVehicleServiceHistory({ accessToken, VIN, callback }); }, [accessToken, getVehicleServiceHistory, VIN]); diff --git a/services/web/src/reducers/profileReducer.ts b/services/web/src/reducers/profileReducer.ts index 7a4796c6..0eb066eb 100644 --- a/services/web/src/reducers/profileReducer.ts +++ b/services/web/src/reducers/profileReducer.ts @@ -18,14 +18,12 @@ import actionTypes from "../constants/actionTypes"; interface ProfileState { videoId: string; - videoData: string; videoName: string; profilePicData: string; } const initialData: ProfileState = { videoId: "", - videoData: "", videoName: "", profilePicData: "", }; @@ -41,30 +39,29 @@ const profileReducer = ( ): ProfileState => { const maction: MyAction = action as { type: string; payload: any }; switch (maction.type) { - case actionTypes.LOGGED_IN: case actionTypes.FETCHED_USER: return { ...state, videoId: maction.payload.video_id, - videoData: maction.payload.video_url, videoName: maction.payload.video_name, profilePicData: maction.payload.picture_url, }; case actionTypes.PROFILE_PIC_CHANGED: return { ...state, - profilePicData: maction.payload.profilePicData, + profilePicData: maction.payload.picture, }; case actionTypes.VIDEO_CHANGED: return { ...state, - videoId: maction.payload.videoId, - videoData: maction.payload.videoData, + videoId: maction.payload.id, + videoName: maction.payload.video_name, }; case actionTypes.VIDEO_NAME_CHANGED: return { ...state, - videoName: maction.payload.videoName, + videoId: maction.payload.id, + videoName: maction.payload.video_name, }; case actionTypes.INVALID_SESSION: return initialData; diff --git a/services/web/src/reducers/userReducer.ts b/services/web/src/reducers/userReducer.ts index 3a56e6b6..cc844b7a 100644 --- a/services/web/src/reducers/userReducer.ts +++ b/services/web/src/reducers/userReducer.ts @@ -103,22 +103,6 @@ const userReducer = ( return initialData; case actionTypes.INVALID_SESSION: return initialData; - case actionTypes.PROFILE_PIC_CHANGED: - return { - ...state, - picture_url: maction.payload.profilePicUrl, - }; - case actionTypes.VIDEO_CHANGED: - return { - ...state, - video_url: maction.payload.videoUrl, - video_id: maction.payload.videoId, - }; - case actionTypes.VIDEO_NAME_CHANGED: - return { - ...state, - video_name: maction.payload.videoName, - }; case actionTypes.BALANCE_CHANGED: return { ...state, diff --git a/services/web/src/sagas/index.ts b/services/web/src/sagas/index.ts index 8794fe02..3adcebbf 100644 --- a/services/web/src/sagas/index.ts +++ b/services/web/src/sagas/index.ts @@ -19,6 +19,7 @@ import { shopActionWatcher } from "./shopSaga"; import { profileActionWatcher } from "./profileSaga"; import { communityActionWatcher } from "./communitySaga"; import { vehicleActionWatcher } from "./vehicleSaga"; +import { mechanicActionWatcher } from "./mechanicSaga"; /** * saga to yield all others @@ -30,5 +31,6 @@ export default function* rootSaga(): Generator { profileActionWatcher(), communityActionWatcher(), vehicleActionWatcher(), + mechanicActionWatcher(), ]); } diff --git a/services/web/src/sagas/mechanicSaga.ts b/services/web/src/sagas/mechanicSaga.ts new file mode 100644 index 00000000..94dd415f --- /dev/null +++ b/services/web/src/sagas/mechanicSaga.ts @@ -0,0 +1,193 @@ +/* + * + * 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. + */ + +import { put, takeLatest } from "redux-saga/effects"; +import { APIService, requestURLS } from "../constants/APIConstant"; +import actionTypes from "../constants/actionTypes"; +import MyAction from "../types/action"; +import responseTypes from "../constants/responseTypes"; +import { NO_SERVICES } from "../constants/messages"; + +/** + * Get the list of services allotted to this mechanic + * @payload {Object} payload + * @payload {string} payload.accessToken - Access token of the user + * @payload {Function} payload.callback - Callback method + */ +export function* getMechanicServices( + action: MyAction, +): Generator { + const { accessToken, callback } = action.payload; + let receivedResponse: Partial = {}; + try { + yield put({ type: actionTypes.FETCHING_DATA }); + const getUrl = + APIService.WORKSHOP_SERVICE + requestURLS.GET_MECHANIC_SERVICES; + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }; + + interface GetServicesResponse { + service_requests: any; + message: string; + } + + const responseJSON: GetServicesResponse = yield fetch(getUrl, { + headers, + method: "GET", + }).then((response: Response) => { + receivedResponse = response; + return response.json(); + }); + + yield put({ type: actionTypes.FETCHED_DATA, payload: responseJSON }); + if (receivedResponse.ok) { + callback(responseTypes.SUCCESS, responseJSON.service_requests); + } else { + callback(responseTypes.FAILURE, responseJSON.message); + } + } catch (e) { + yield put({ type: actionTypes.FETCHED_DATA, payload: receivedResponse }); + callback(responseTypes.FAILURE, NO_SERVICES); + } +} + +/** + * Get the list of services allotted to this mechanic + * @payload {Object} payload + * @payload {string} payload.accessToken - Access token of the user + * @payload {Function} payload.callback - Callback method + */ +export function* getMechanicService( + action: MyAction, +): Generator { + const { accessToken, serviceId, callback } = action.payload; + let receivedResponse: Partial = {}; + try { + yield put({ type: actionTypes.FETCHING_DATA }); + const getUrl = + APIService.WORKSHOP_SERVICE + + requestURLS.GET_MECHANIC_SERVICE.replace("", serviceId); + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }; + + interface GetServicesResponse { + service_request: any; + message: string; + } + + const responseJSON: GetServicesResponse = yield fetch(getUrl, { + headers, + method: "GET", + }).then((response: Response) => { + receivedResponse = response; + return response.json(); + }); + + yield put({ type: actionTypes.FETCHED_DATA, payload: responseJSON }); + if (receivedResponse.ok) { + callback(responseTypes.SUCCESS, responseJSON); + } else { + callback(responseTypes.FAILURE, responseJSON.message); + } + } catch (e) { + yield put({ type: actionTypes.FETCHED_DATA, payload: receivedResponse }); + callback(responseTypes.FAILURE, NO_SERVICES); + } +} + +export function* createComment(action: MyAction): Generator { + const { accessToken, serviceId, comment, callback } = action.payload; + console.log("Comment", comment, serviceId); + let receivedResponse: Partial = {}; + try { + yield put({ type: actionTypes.FETCHING_DATA }); + const getUrl = + APIService.WORKSHOP_SERVICE + + requestURLS.CREATE_SERVICE_COMMENT.replace("", serviceId); + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }; + const responseJSON = yield fetch(getUrl, { + headers, + method: "POST", + body: JSON.stringify({ comment }), + }).then((response: Response) => { + receivedResponse = response; + return response.json(); + }); + yield put({ type: actionTypes.FETCHED_DATA, payload: responseJSON }); + if (receivedResponse.ok) { + callback(responseTypes.SUCCESS, responseJSON); + } else { + callback(responseTypes.FAILURE, responseJSON.message); + } + } catch (e) { + yield put({ type: actionTypes.FETCHED_DATA, payload: receivedResponse }); + callback(responseTypes.FAILURE, e); + } +} + +export function* updateServiceRequestStatus( + action: MyAction, +): Generator { + const { accessToken, serviceId, status, callback } = action.payload; + console.log("Status", status, serviceId); + let receivedResponse: Partial = {}; + try { + yield put({ type: actionTypes.FETCHING_DATA }); + const getUrl = + APIService.WORKSHOP_SERVICE + + requestURLS.UPDATE_SERVICE_REQUEST_STATUS.replace( + "", + serviceId, + ); + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }; + const responseJSON = yield fetch(getUrl, { + headers, + method: "PUT", + body: JSON.stringify({ status }), + }).then((response: Response) => { + receivedResponse = response; + return response.json(); + }); + yield put({ type: actionTypes.FETCHED_DATA, payload: responseJSON }); + if (receivedResponse.ok) { + callback(responseTypes.SUCCESS, responseJSON); + } else { + callback(responseTypes.FAILURE, responseJSON.message); + } + } catch (e) { + yield put({ type: actionTypes.FETCHED_DATA, payload: receivedResponse }); + callback(responseTypes.FAILURE, e); + } +} + +export function* mechanicActionWatcher(): Generator { + yield takeLatest(actionTypes.GET_MECHANIC_SERVICES, getMechanicServices); + yield takeLatest(actionTypes.GET_MECHANIC_SERVICE, getMechanicService); + yield takeLatest(actionTypes.CREATE_SERVICE_COMMENT, createComment); + yield takeLatest( + actionTypes.UPDATE_SERVICE_REQUEST_STATUS, + updateServiceRequestStatus, + ); +} diff --git a/services/web/src/sagas/profileSaga.ts b/services/web/src/sagas/profileSaga.ts index ba004c30..bb0e3323 100644 --- a/services/web/src/sagas/profileSaga.ts +++ b/services/web/src/sagas/profileSaga.ts @@ -18,13 +18,12 @@ import { APIService, requestURLS } from "../constants/APIConstant"; import actionTypes from "../constants/actionTypes"; import responseTypes from "../constants/responseTypes"; import { - PROFILE_PIC_UPDATED, PROFILE_PIC_NOT_UPDATED, - VIDEO_UPDATED, VIDEO_NOT_UPDATED, VIDEO_NAME_CHANGED, VIDEO_NAME_NOT_CHANGED, VIDEO_NOT_CONVERTED, + FAILURE_MESSAGE, } from "../constants/messages"; import MyAction from "../types/action"; @@ -64,9 +63,9 @@ export function* uploadProfilePic(action: MyAction): Generator { if (recievedResponse.ok) { yield put({ type: actionTypes.PROFILE_PIC_CHANGED, - payload: { profilePicData: responseJson.picture }, + payload: responseJson, }); - callback(responseTypes.SUCCESS, PROFILE_PIC_UPDATED); + callback(responseTypes.SUCCESS, responseJson); } else { callback(responseTypes.FAILURE, responseJson.message); } @@ -107,12 +106,9 @@ export function* uploadVideo(action: MyAction): Generator { if (recievedResponse.ok) { yield put({ type: actionTypes.VIDEO_CHANGED, - payload: { - videoData: responseJson.profileVideo, - videoId: responseJson.id, - }, + payload: responseJson, }); - callback(responseTypes.SUCCESS, VIDEO_UPDATED); + callback(responseTypes.SUCCESS, responseJson); } else { callback(responseTypes.FAILURE, responseJson.message); } @@ -154,9 +150,9 @@ export function* changeVideoName(action: MyAction): Generator { if (recievedResponse.ok) { yield put({ type: actionTypes.VIDEO_NAME_CHANGED, - payload: { videoName: responseJson.video_name }, + payload: responseJson, }); - callback(responseTypes.SUCCESS, VIDEO_NAME_CHANGED); + callback(responseTypes.SUCCESS, responseJson); } else { callback(responseTypes.FAILURE, responseJson.message); } @@ -202,9 +198,46 @@ export function* convertVideo(action: MyAction): Generator { } } +export function* getVideo(action: MyAction): Generator { + const { accessToken, videoId, callback } = action.payload; + let recievedResponse: ReceivedResponse = {} as ReceivedResponse; + try { + yield put({ type: actionTypes.FETCHING_DATA }); + const getUrl = + APIService.IDENTITY_SERVICE + + requestURLS.GET_VIDEO.replace("", videoId); + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }; + const responseJson = yield fetch(getUrl, { + headers, + method: "GET", + }).then((response: Response) => { + recievedResponse = response as ReceivedResponse; + return response.json(); + }); + + yield put({ type: actionTypes.FETCHED_DATA, payload: recievedResponse }); + if (recievedResponse.ok) { + yield put({ + type: actionTypes.VIDEO_CHANGED, + payload: responseJson, + }); + callback(responseTypes.SUCCESS, responseJson); + } else { + callback(responseTypes.FAILURE, FAILURE_MESSAGE); + } + } catch (e) { + yield put({ type: actionTypes.FETCHED_DATA, payload: recievedResponse }); + callback(responseTypes.FAILURE, VIDEO_NOT_CONVERTED); + } +} + export function* profileActionWatcher(): Generator { yield takeLatest(actionTypes.UPLOAD_PROFILE_PIC, uploadProfilePic); yield takeLatest(actionTypes.UPLOAD_VIDEO, uploadVideo); yield takeLatest(actionTypes.CHANGE_VIDEO_NAME, changeVideoName); yield takeLatest(actionTypes.CONVERT_VIDEO, convertVideo); + yield takeLatest(actionTypes.GET_VIDEO, getVideo); } diff --git a/services/web/src/sagas/vehicleSaga.ts b/services/web/src/sagas/vehicleSaga.ts index 529a7d78..55c27730 100644 --- a/services/web/src/sagas/vehicleSaga.ts +++ b/services/web/src/sagas/vehicleSaga.ts @@ -300,51 +300,6 @@ export function* refreshLocation(action: MyAction): Generator { } } -/** - * Get the list of services allotted to this mechanic - * @payload {Object} payload - * @payload {string} payload.accessToken - Access token of the user - * @payload {Function} payload.callback - Callback method - */ -export function* getMechanicServices( - action: MyAction, -): Generator { - const { accessToken, callback } = action.payload; - let receivedResponse: Partial = {}; - try { - yield put({ type: actionTypes.FETCHING_DATA }); - const getUrl = - APIService.WORKSHOP_SERVICE + requestURLS.GET_MECHANIC_SERVICES; - const headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }; - - interface GetServicesResponse { - service_requests: any; - message: string; - } - - const responseJSON: GetServicesResponse = yield fetch(getUrl, { - headers, - method: "GET", - }).then((response: Response) => { - receivedResponse = response; - return response.json(); - }); - - yield put({ type: actionTypes.FETCHED_DATA, payload: responseJSON }); - if (receivedResponse.ok) { - callback(responseTypes.SUCCESS, responseJSON.service_requests); - } else { - callback(responseTypes.FAILURE, responseJSON.message); - } - } catch (e) { - yield put({ type: actionTypes.FETCHED_DATA, payload: receivedResponse }); - callback(responseTypes.FAILURE, NO_SERVICES); - } -} - export function* getVehicleServices( action: MyAction, ): Generator { @@ -437,7 +392,6 @@ export function* vehicleActionWatcher(): Generator { yield takeLatest(actionTypes.GET_MECHANICS, getMechanics); yield takeLatest(actionTypes.CONTACT_MECHANIC, contactMechanic); yield takeLatest(actionTypes.REFRESH_LOCATION, refreshLocation); - yield takeLatest(actionTypes.GET_MECHANIC_SERVICES, getMechanicServices); - yield takeLatest(actionTypes.GET_VEHICLE_SERVICES, getVehicleServices); yield takeLatest(actionTypes.GET_SERVICE_REPORT, getServiceReport); + yield takeLatest(actionTypes.GET_VEHICLE_SERVICES, getVehicleServices); } diff --git a/services/workshop/crapi/mechanic/models.py b/services/workshop/crapi/mechanic/models.py index 79cd9323..80201df3 100644 --- a/services/workshop/crapi/mechanic/models.py +++ b/services/workshop/crapi/mechanic/models.py @@ -56,7 +56,10 @@ class ServiceRequest(models.Model): updated_on = models.DateTimeField(null=True) STATUS_CHOICES = Choices( - ("PEN", "pending", "Pending"), ("FIN", "finished", "Finished") + ("PEN", "pending", "Pending"), + ("FIN", "completed", "Completed"), + ("CAN", "cancelled", "Cancelled"), + ("INP", "inprogress", "In Progress"), ) status = models.CharField( max_length=10, choices=STATUS_CHOICES, default=STATUS_CHOICES.PEN @@ -67,3 +70,21 @@ class Meta: def __str__(self): return f"" + + +class ServiceComment(models.Model): + """ + Service Comment Model + represents the comments of a service request + """ + + id = models.AutoField(primary_key=True) + service_request = ForeignKey(ServiceRequest, DB_CASCADE) + comment = models.CharField(max_length=500, blank=True) + created_on = models.DateTimeField() + + class Meta: + db_table = "service_comment" + + def __str__(self): + return f"" diff --git a/services/workshop/crapi/mechanic/serializers.py b/services/workshop/crapi/mechanic/serializers.py index 939be122..0ee38a4d 100644 --- a/services/workshop/crapi/mechanic/serializers.py +++ b/services/workshop/crapi/mechanic/serializers.py @@ -17,7 +17,7 @@ """ from rest_framework import serializers -from crapi.mechanic.models import Mechanic, ServiceRequest +from crapi.mechanic.models import Mechanic, ServiceRequest, ServiceComment from crapi.user.serializers import UserSerializer, VehicleSerializer @@ -42,9 +42,17 @@ class MechanicServiceRequestSerializer(serializers.ModelSerializer): Serializer for Mechanic model """ + def get_comments(self, obj): + comments = ServiceComment.objects.filter(service_request=obj).order_by( + "-created_on" + ) + return ServiceCommentViewSerializer(comments, many=True).data + + comments = serializers.SerializerMethodField() mechanic = MechanicSerializer() vehicle = VehicleSerializer() created_on = serializers.DateTimeField(format="%d %B, %Y, %H:%M:%S") + updated_on = serializers.DateTimeField(format="%d %B, %Y, %H:%M:%S") class Meta: """ @@ -59,6 +67,8 @@ class Meta: "problem_details", "status", "created_on", + "updated_on", + "comments", ) @@ -83,3 +93,45 @@ class SignUpSerializer(serializers.Serializer): number = serializers.CharField() password = serializers.CharField() mechanic_code = serializers.CharField() + + +class ServiceCommentViewSerializer(serializers.ModelSerializer): + """ + Serializer for ServiceComment model + """ + + class Meta: + """ + Meta class for ServiceCommentViewSerializer + """ + + model = ServiceComment + fields = ("id", "comment", "created_on") + + +class ServiceCommentCreateSerializer(serializers.ModelSerializer): + """ + Serializer for ServiceComment creation model + """ + + class Meta: + """ + Meta class for ServiceCommentCreateSerializer + """ + + model = ServiceComment + fields = ["comment"] + + +class ServiceRequestStatusUpdateSerializer(serializers.ModelSerializer): + """ + Serializer for ServiceRequest to update the status + """ + + class Meta: + """ + Meta class for ServiceRequestStatusUpdateSerializer + """ + + model = ServiceRequest + fields = ["status"] diff --git a/services/workshop/crapi/mechanic/tests.py b/services/workshop/crapi/mechanic/tests.py index 7e33142c..8fb506d5 100644 --- a/services/workshop/crapi/mechanic/tests.py +++ b/services/workshop/crapi/mechanic/tests.py @@ -13,8 +13,15 @@ """ contains all the test cases related to mechanic """ +from django.utils import timezone from unittest.mock import patch -from utils.mock_methods import get_sample_mechanic_data, mock_jwt_auth_required +from utils.mock_methods import ( + get_sample_mechanic_data, + mock_jwt_auth_required, + get_sample_user_data, +) +from crapi.mechanic.models import Mechanic, ServiceRequest, User, ServiceComment +from crapi.user.models import Vehicle, VehicleCompany, VehicleModel patch("utils.jwt.jwt_auth_required", mock_jwt_auth_required).start() @@ -162,3 +169,148 @@ def test_bad_request(self): content_type="application/json", ) self.assertEqual(res.status_code, 400) + + +class MechanicServiceWorkFlowTestCase(TestCase): + """ + contains all the test cases related to Mechanic Service WorkFlow + """ + + def setUp(self): + """ + stores a sample request body for mechanic + creates a dummy mechanic, a dummy user, a dummy vehicle and corresponding auth tokens + stores a sample request body for contact mechanic + creates a dummy service request + :return: None + """ + self.client = Client() + mechanic_data = get_sample_mechanic_data() + self.mechanic = Mechanic.objects.create( + id=1, + mechanic_code=mechanic_data["mechanic_code"], + user=User.objects.create( + id=1, + email=mechanic_data["email"], + number=mechanic_data["number"], + password=mechanic_data["password"], + role=User.ROLE_CHOICES.MECH, + created_on=timezone.now(), + ), + ) + + self.client.post( + "/workshop/api/mechanic/signup", + self.mechanic, + content_type="application/json", + ) + + user_data = get_sample_user_data() + self.user = User.objects.create( + id=2, + email=user_data["email"], + number=user_data["number"], + password=user_data["password"], + role=User.ROLE_CHOICES.USER, + created_on=timezone.now(), + ) + self.user_auth_headers = {"HTTP_AUTHORIZATION": "Bearer " + user_data["email"]} + + self.mechanic_auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + self.mechanic.user.email + } + + self.vehicle_company = VehicleCompany.objects.create(name="RandomCompany") + + self.vehicle_model = VehicleModel.objects.create( + fuel_type="1", + model="NewModel", + vehicle_img="Image", + vehiclecompany=self.vehicle_company, + ) + + self.vehicle = Vehicle.objects.create( + pincode="1234", + vin="9NFXO86WBWA082766", + year="2020", + status="ACTIVE", + owner=self.user, + vehicle_model=self.vehicle_model, + ) + self.contact_mechanic_request_body = { + "mechanic_api": "https://www.google.com", + "repeat_request_if_failed": True, + "number_of_repeats": 5, + "mechanic_code": self.mechanic.mechanic_code, + "vin": self.vehicle.vin, + "problem_details": "My Car is not working", + } + self.service_request = ServiceRequest.objects.create( + vehicle=self.vehicle, + mechanic=self.mechanic, + problem_details="My Car is not working", + status="PENDING", + created_on=timezone.now(), + updated_on=timezone.now(), + ) + + def test_create_comment(self): + """ + creates a dummy service request + creates a dummy comment for the service request + should get a valid response on creating comment + :return: None + """ + res = self.client.post( + "/workshop/api/mechanic/service_request/%s/comment" + % self.service_request.id, + {"comment": "This is a test comment"}, + content_type="application/json", + **self.mechanic_auth_headers + ) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json()["comment"], "This is a test comment") + + def test_update_service_request(self): + """ + updates the status of the service request + should get a valid response on updating the service request + :return: None + """ + res = self.client.put( + "/workshop/api/mechanic/service_request/%s" % self.service_request.id, + {"status": "inprogress"}, + content_type="application/json", + **self.mechanic_auth_headers + ) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json()["status"], "inprogress") + + def test_get_multiple_comments(self): + """ + creates multiple comments for a service request + should get a valid response on getting multiple comments + :return: None + """ + comments_len = ServiceComment.objects.filter( + service_request_id=self.service_request.id + ).count() + res = self.client.post( + "/workshop/api/mechanic/service_request/%s/comment" + % self.service_request.id, + {"comment": "This is another test comment"}, + content_type="application/json", + **self.mechanic_auth_headers + ) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json()["comment"], "This is another test comment") + + comments = self.client.get( + "/workshop/api/mechanic/service_request/%s/comment" + % self.service_request.id, + content_type="application/json", + **self.mechanic_auth_headers + ) + self.assertEqual(comments.status_code, 200) + print(comments.json()) + self.assertEqual(len(comments.json()), comments_len + 1) diff --git a/services/workshop/crapi/mechanic/urls.py b/services/workshop/crapi/mechanic/urls.py index df257235..be7a0d6f 100644 --- a/services/workshop/crapi/mechanic/urls.py +++ b/services/workshop/crapi/mechanic/urls.py @@ -28,6 +28,18 @@ mechanic_views.GetReportView.as_view(), name="get-mechanic-report", ), + re_path( + r"service_request/(?P[0-9]+)/comment$", + mechanic_views.ServiceCommentView.as_view(), + ), + re_path( + r"service_request/(?P[0-9]+)$", + mechanic_views.ServiceRequestView.as_view(), + ), re_path(r"service_requests$", mechanic_views.MechanicServiceRequestsView.as_view()), + re_path( + r"service_request$", + mechanic_views.MechanicServiceRequestsView.as_view(), + ), re_path(r"$", mechanic_views.MechanicView.as_view()), ] diff --git a/services/workshop/crapi/mechanic/views.py b/services/workshop/crapi/mechanic/views.py index 2615595c..efd1405d 100644 --- a/services/workshop/crapi/mechanic/views.py +++ b/services/workshop/crapi/mechanic/views.py @@ -28,12 +28,15 @@ from utils import messages from crapi.user.models import User, Vehicle, UserDetails from utils.logging import log_error -from .models import Mechanic, ServiceRequest +from .models import Mechanic, ServiceRequest, ServiceComment from .serializers import ( MechanicSerializer, MechanicServiceRequestSerializer, ReceiveReportSerializer, SignUpSerializer, + ServiceRequestStatusUpdateSerializer, + ServiceCommentCreateSerializer, + ServiceCommentViewSerializer, ) from rest_framework.pagination import LimitOffsetPagination @@ -258,7 +261,7 @@ def get(self, request, user=None): """ service_requests = ServiceRequest.objects.filter(mechanic__user=user).order_by( - "id" + "-created_on" ) paginated = self.paginate_queryset(service_requests, request) if paginated is None: @@ -280,3 +283,86 @@ def get(self, request, user=None): count=self.get_count(paginated), ) return Response(response_data, status=status.HTTP_200_OK) + + +class ServiceCommentView(APIView): + """ + View to add a comment to a service request + """ + + @jwt_auth_required + def post(self, request, user=None, service_request_id=None): + """ + add a comment to a service request + """ + if user.role != User.ROLE_CHOICES.MECH: + return Response( + {"message": messages.UNAUTHORIZED}, + status=status.HTTP_401_UNAUTHORIZED, + ) + serializer = ServiceCommentCreateSerializer(data=request.data) + if not serializer.is_valid(): + log_error( + request.path, + request.data, + status.HTTP_400_BAD_REQUEST, + serializer.errors, + ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + service_request = ServiceRequest.objects.get(id=service_request_id) + service_comment = ServiceComment( + comment=serializer.data["comment"], + service_request=service_request, + created_on=timezone.now(), + ) + service_comment.save() + service_request.updated_on = timezone.now() + service_request.save() + serializer = ServiceCommentViewSerializer(service_comment) + return Response(serializer.data, status=status.HTTP_200_OK) + + @jwt_auth_required + def get(self, request, user=None, service_request_id=None): + """ + get all comments for a service request + """ + service_request = ServiceRequest.objects.get(id=service_request_id) + comments = ServiceComment.objects.filter(service_request=service_request) + serializer = ServiceCommentViewSerializer(comments, many=True) + response_data = dict(comments=serializer.data) + return Response(response_data, status=status.HTTP_200_OK) + + +class ServiceRequestView(APIView): + """ + View to update the status of a service request + """ + + @jwt_auth_required + def put(self, request, user=None, service_request_id=None): + """ + update the status of a service request + """ + serializer = ServiceRequestStatusUpdateSerializer(data=request.data) + if not serializer.is_valid(): + log_error( + request.path, + request.data, + status.HTTP_400_BAD_REQUEST, + serializer.errors, + ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + service_request = ServiceRequest.objects.get(id=service_request_id) + service_request.status = request.data["status"] + service_request.updated_on = timezone.now() + service_request.save() + serializer = MechanicServiceRequestSerializer(service_request) + return Response(serializer.data, status=status.HTTP_200_OK) + + def get(self, request, user=None, service_request_id=None): + """ + get a service request + """ + service_request = ServiceRequest.objects.get(id=service_request_id) + serializer = MechanicServiceRequestSerializer(service_request) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/services/workshop/crapi/merchant/serializers.py b/services/workshop/crapi/merchant/serializers.py index c840085f..00c2f2f7 100644 --- a/services/workshop/crapi/merchant/serializers.py +++ b/services/workshop/crapi/merchant/serializers.py @@ -16,8 +16,8 @@ contains serializers for Merchant APIs """ from rest_framework import serializers -from crapi.mechanic.models import Mechanic, ServiceRequest -from crapi.mechanic.serializers import VehicleSerializer +from crapi.mechanic.models import Mechanic, ServiceRequest, ServiceComment +from crapi.mechanic.serializers import VehicleSerializer, ServiceCommentViewSerializer class ContactMechanicSerializer(serializers.Serializer): @@ -46,12 +46,19 @@ class Meta: class UserServiceRequestSerializer(serializers.ModelSerializer): """ - Serializer for Mechanic model + Serializer for ServiceRequest model """ + comments = serializers.SerializerMethodField() + mechanic = MechanicPublicSerializer() vehicle = VehicleSerializer() created_on = serializers.DateTimeField(format="%d %B, %Y, %H:%M:%S") + updated_on = serializers.DateTimeField(format="%d %B, %Y, %H:%M:%S") + + def get_comments(self, obj): + service_comments = ServiceComment.objects.filter(service_request_id=obj.id) + return ServiceCommentViewSerializer(service_comments, many=True).data class Meta: """ @@ -66,4 +73,6 @@ class Meta: "problem_details", "status", "created_on", + "updated_on", + "comments", ) diff --git a/services/workshop/crapi/merchant/views.py b/services/workshop/crapi/merchant/views.py index b9220629..1a276e84 100644 --- a/services/workshop/crapi/merchant/views.py +++ b/services/workshop/crapi/merchant/views.py @@ -21,14 +21,18 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView -from crapi.merchant.serializers import ContactMechanicSerializer +from crapi.mechanic.serializers import ( + ServiceCommentViewSerializer, + ServiceCommentCreateSerializer, +) from utils.jwt import jwt_auth_required from utils import messages from rest_framework.pagination import LimitOffsetPagination from utils.logging import log_error from crapi_site import settings -from crapi.mechanic.models import ServiceRequest -from .serializers import UserServiceRequestSerializer +from crapi.mechanic.models import ServiceRequest, ServiceComment +from .serializers import ContactMechanicSerializer, UserServiceRequestSerializer + logger = logging.getLogger() @@ -124,6 +128,32 @@ def post(self, request, user=None): ) +class UserServiceCommentView(APIView): + """ + View to add a comment to a service request + """ + + @jwt_auth_required + def get(self, request, user=None, service_request_id=None): + """ + get all comments for a service request + """ + service_request = ServiceRequest.objects.get(id=service_request_id) + if not service_request: + return Response( + {"message": messages.NO_OBJECT_FOUND}, + status=status.HTTP_404_NOT_FOUND, + ) + if service_request.vehicle.owner.id != user.id: + return Response( + {"message": messages.NO_OBJECT_FOUND}, + status=status.HTTP_404_NOT_FOUND, + ) + comments = ServiceComment.objects.filter(service_request=service_request) + serializer = ServiceCommentViewSerializer(comments, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + class UserServiceRequestsView(APIView, LimitOffsetPagination): """ View to return all the service requests diff --git a/services/workshop/crapi/migrations/0004_alter_servicerequest_status_servicecomment.py b/services/workshop/crapi/migrations/0004_alter_servicerequest_status_servicecomment.py new file mode 100644 index 00000000..2818849c --- /dev/null +++ b/services/workshop/crapi/migrations/0004_alter_servicerequest_status_servicecomment.py @@ -0,0 +1,47 @@ +# Generated by Django 4.1.13 on 2024-08-30 17:35 + +from django.db import migrations, models +import django_db_cascade.deletions +import django_db_cascade.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("crapi", "0003_alter_appliedcoupon_id_alter_mechanic_id_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="servicerequest", + name="status", + field=models.CharField( + choices=[ + ("pending", "Pending"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ("inprogress", "In Progress"), + ], + default="pending", + max_length=10, + ), + ), + migrations.CreateModel( + name="ServiceComment", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("comment", models.CharField(blank=True, max_length=500)), + ("created_on", models.DateTimeField()), + ( + "service_request", + django_db_cascade.fields.ForeignKey( + on_delete=django_db_cascade.deletions.DB_CASCADE, + to="crapi.servicerequest", + ), + ), + ], + options={ + "db_table": "service_comment", + }, + ), + ]