diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 6fc2c725..19ca9a20 100755 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -130,6 +130,7 @@ services: - TLS_ENABLED=${TLS_ENABLED:-false} - TLS_CERTIFICATE=certs/server.crt - TLS_KEY=certs/server.key + - FILES_LIMIT=1000 depends_on: postgresdb: condition: service_healthy diff --git a/deploy/helm/templates/workshop/config.yaml b/deploy/helm/templates/workshop/config.yaml index 96ef3e96..3b3f7431 100644 --- a/deploy/helm/templates/workshop/config.yaml +++ b/deploy/helm/templates/workshop/config.yaml @@ -22,3 +22,4 @@ data: SERVER_PORT: {{ .Values.workshop.port | quote }} API_GATEWAY_URL: {{ if .Values.apiGatewayServiceInstall }}"https://{{ .Values.apiGatewayService.service.name }}"{{ else }}{{ .Values.apiGatewayServiceUrl }}{{ end }} TLS_ENABLED: {{ .Values.tlsEnabled | quote }} + FILES_LIMIT: {{ .Values.workshop.config.filesLimit }} diff --git a/deploy/helm/values.yaml b/deploy/helm/values.yaml index 3146869e..5b285c62 100644 --- a/deploy/helm/values.yaml +++ b/deploy/helm/values.yaml @@ -126,6 +126,7 @@ workshop: postgresDbDriver: postgres mongoDbDriver: mongodb secretKey: crapi + filesLimit: 1000 deploymentLabels: app: crapi-workshop podLabels: diff --git a/services/web/src/components/serviceReport/serviceReport.tsx b/services/web/src/components/serviceReport/serviceReport.tsx index b4d01da5..475e68e0 100644 --- a/services/web/src/components/serviceReport/serviceReport.tsx +++ b/services/web/src/components/serviceReport/serviceReport.tsx @@ -14,16 +14,7 @@ */ import React from "react"; -import { - Card, - Row, - Col, - Descriptions, - Spin, - Layout, - Timeline, - Typography, -} from "antd"; +import { Card, Descriptions, Spin, Layout, Timeline, Typography } from "antd"; import { PageHeader } from "@ant-design/pro-components"; import { Content } from "antd/es/layout/layout"; import { @@ -33,6 +24,7 @@ import { ToolOutlined, CommentOutlined, CalendarOutlined, + DownloadOutlined, } from "@ant-design/icons"; import "./styles.css"; @@ -65,6 +57,7 @@ interface Service { comment: string; created_on: string; }[]; + downloadUrl?: string; } interface ServiceReportProps { @@ -126,6 +119,18 @@ const ServiceReport: React.FC = ({ service }) => { } + extra={[ + + + Download Report + , + ]} /> diff --git a/services/web/src/components/serviceReport/styles.css b/services/web/src/components/serviceReport/styles.css index bac41f50..aad4ac6f 100644 --- a/services/web/src/components/serviceReport/styles.css +++ b/services/web/src/components/serviceReport/styles.css @@ -219,6 +219,34 @@ gap: var(--spacing-sm); } +/* Download Report button */ +.download-report-button { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + background: linear-gradient(135deg, #8b5cf6 0%, #a855f7 100%); + color: white; + text-decoration: none; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--border-radius-lg); + font-size: 14px; + font-weight: 600; + transition: all 0.3s ease; + border: none; + cursor: pointer; + box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3); + width: 100%; + justify-content: center; +} + +.download-report-button:hover, .download-report-button:focus { + background: linear-gradient(135deg, #7c3aed 0%, #9333ea 100%); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(139, 92, 246, 0.4); + color: white; + text-decoration: none; +} + /* Loading State */ .loading-container { display: flex; @@ -251,6 +279,11 @@ padding: var(--spacing-md); } + .download-report-button { + padding: var(--spacing-md); + font-size: 13px; + } + .ant-descriptions-item-label { font-size: 12px; } diff --git a/services/web/src/constants/APIConstant.ts b/services/web/src/constants/APIConstant.ts index 5de298fe..63d3fc2b 100644 --- a/services/web/src/constants/APIConstant.ts +++ b/services/web/src/constants/APIConstant.ts @@ -65,6 +65,7 @@ export const requestURLS: RequestURLSType = { UPDATE_SERVICE_REQUEST_STATUS: "api/mechanic/service_request/", GET_VEHICLE_SERVICES: "api/merchant/service_requests/", GET_SERVICE_REPORT: "api/mechanic/mechanic_report", + DOWNLOAD_SERVICE_REPORT: "api/mechanic/download_report", BUY_PRODUCT: "api/shop/orders", GET_ORDERS: "api/shop/orders/all", GET_ORDER_BY_ID: "api/shop/orders/", diff --git a/services/web/src/containers/serviceReport/serviceReport.tsx b/services/web/src/containers/serviceReport/serviceReport.tsx index c493fba9..ba5280d2 100644 --- a/services/web/src/containers/serviceReport/serviceReport.tsx +++ b/services/web/src/containers/serviceReport/serviceReport.tsx @@ -52,6 +52,7 @@ interface Service { comment: string; created_on: string; }[]; + downloadUrl?: string; } const mapStateToProps = (state: RootState) => ({ diff --git a/services/web/src/sagas/vehicleSaga.ts b/services/web/src/sagas/vehicleSaga.ts index 55c27730..c423d571 100644 --- a/services/web/src/sagas/vehicleSaga.ts +++ b/services/web/src/sagas/vehicleSaga.ts @@ -377,6 +377,12 @@ export function* getServiceReport(action: MyAction): Generator { throw responseJSON; } + const filename = `report_${reportId}`; + responseJSON.downloadUrl = + APIService.WORKSHOP_SERVICE + + requestURLS.DOWNLOAD_SERVICE_REPORT + + "?filename=" + + filename; yield put({ type: actionTypes.FETCHED_DATA, payload: responseJSON }); callback(responseTypes.SUCCESS, responseJSON); } catch (e) { diff --git a/services/workshop/crapi/mechanic/urls.py b/services/workshop/crapi/mechanic/urls.py index be7a0d6f..8050d779 100644 --- a/services/workshop/crapi/mechanic/urls.py +++ b/services/workshop/crapi/mechanic/urls.py @@ -41,5 +41,6 @@ r"service_request$", mechanic_views.MechanicServiceRequestsView.as_view(), ), + re_path(r"download_report$", mechanic_views.DownloadReportView.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 efd1405d..684cdd63 100644 --- a/services/workshop/crapi/mechanic/views.py +++ b/services/workshop/crapi/mechanic/views.py @@ -15,7 +15,12 @@ """ contains all the views related to Mechanic """ +import os import bcrypt +import re +from urllib.parse import unquote +from django.template.loader import get_template +from xhtml2pdf import pisa from django.utils import timezone from django.views.decorators.csrf import csrf_exempt from django.urls import reverse @@ -23,6 +28,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from django.db import models +from django.http import FileResponse from crapi_site import settings from utils.jwt import jwt_auth_required from utils import messages @@ -40,7 +46,6 @@ ) from rest_framework.pagination import LimitOffsetPagination - class SignUpView(APIView): """ Used to add a new mechanic @@ -235,6 +240,7 @@ def get(self, request, user=None): ) serializer = MechanicServiceRequestSerializer(service_request) response_data = dict(serializer.data) + service_report_pdf(response_data, report_id) return Response(response_data, status=status.HTTP_200_OK) @@ -366,3 +372,89 @@ def get(self, request, user=None, service_request_id=None): service_request = ServiceRequest.objects.get(id=service_request_id) serializer = MechanicServiceRequestSerializer(service_request) return Response(serializer.data, status=status.HTTP_200_OK) + + +class DownloadReportView(APIView): + """ + A view to download a service report. + """ + def get(self, request, format=None): + filename_from_user = request.query_params.get('filename') + if not filename_from_user: + return Response( + {"message": "Parameter 'filename' is required."}, + status=status.HTTP_400_BAD_REQUEST + ) + #Checks if input before decoding contains only allowed characters + if not validate_filename(filename_from_user): + return Response( + {"message": "Invalid input."}, + status=status.HTTP_400_BAD_REQUEST + ) + + filename_from_user = unquote(filename_from_user) + full_path = os.path.abspath(os.path.join(settings.BASE_DIR, "reports", filename_from_user)) + if os.path.exists(full_path) and os.path.isfile(full_path): + return FileResponse(open(full_path, 'rb')) + elif not os.path.exists(full_path): + return Response( + {"message": f"File not found at '{full_path}'."}, + status=status.HTTP_404_NOT_FOUND + ) + else: + return Response( + {"message": f"'{full_path}' is not a file."}, + status=status.HTTP_403_FORBIDDEN + ) + +def validate_filename(input: str) -> bool: + """ + Allowed: alphanumerics, _, :, %HH + """ + url_encoded_pattern = re.compile(r'^(?:[A-Za-z0-9:_]|%[0-9A-Fa-f]{2})*$') + return bool(url_encoded_pattern.fullmatch(input)) + + +def service_report_pdf(response_data, report_id): + """ + Generates service report's PDF file from a template and saves it to the disk. + """ + reports_dir = os.path.join(settings.BASE_DIR, 'reports') + os.makedirs(reports_dir, exist_ok=True) + report_filepath = os.path.join(reports_dir, f"report_{report_id}") + + template = get_template('service_report.html') + html_string = template.render({'service': response_data}) + with open(report_filepath, "w+b") as pdf_file: + pisa.CreatePDF(src=html_string, dest=pdf_file) + + manage_reports_directory() + + +def manage_reports_directory(): + """ + Checks reports directory and deletes the oldest one if the + count exceeds the maximum limit. + """ + try: + reports_dir = os.path.join(settings.BASE_DIR, 'reports') + report_files = os.listdir(reports_dir) + + if len(report_files) >= settings.FILES_LIMIT: + oldest_file = None + oldest_time = float('inf') + for filename in report_files: + filepath = os.path.join(reports_dir, filename) + try: + current_mtime = os.path.getmtime(filepath) + if current_mtime < oldest_time: + oldest_time = current_mtime + oldest_file = filepath + except FileNotFoundError: + continue + + if oldest_file: + os.remove(oldest_file) + + except (OSError, FileNotFoundError) as e: + print(f"Error during report directory management: {e}") \ No newline at end of file diff --git a/services/workshop/crapi_site/settings.py b/services/workshop/crapi_site/settings.py index 2f1e9359..0e88c97b 100644 --- a/services/workshop/crapi_site/settings.py +++ b/services/workshop/crapi_site/settings.py @@ -41,6 +41,8 @@ def get_env_value(env_variable): raise ImproperlyConfigured(error_msg) +FILES_LIMIT = int(os.environ.get("FILES_LIMIT", 1000)) + # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -108,7 +110,7 @@ def get_env_value(env_variable): TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], + "DIRS": [os.path.join(BASE_DIR, 'utils')], "APP_DIRS": True, "OPTIONS": { "context_processors": [ diff --git a/services/workshop/requirements.txt b/services/workshop/requirements.txt index 6c71f547..c10137dd 100644 --- a/services/workshop/requirements.txt +++ b/services/workshop/requirements.txt @@ -22,3 +22,4 @@ gunicorn==21.2.0 coverage==7.4.1 unittest-xml-reporting==3.2.0 black==24.4.2 +xhtml2pdf==0.2.17 \ No newline at end of file diff --git a/services/workshop/utils/service_report.html b/services/workshop/utils/service_report.html new file mode 100644 index 00000000..977d5686 --- /dev/null +++ b/services/workshop/utils/service_report.html @@ -0,0 +1,227 @@ + + + + + Service Report - {{ service.id }} + + + +
+
+
+

Service Report

+

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

+
+
+ +
+
+
+
{{ service.status|upper }}
+
Report Details
+
+ + + + + + + + + + + + + + + + + + + +
Report ID{{ service.id }}
Service Status{{ service.status }}
Created On{{ service.created_on }}
Problem Details{{ service.problem_details }}
+
+
+ +
+
Service Comments
+
+
+ {% for comment in service.comments %} +
+
{{ comment.created_on }}
+
{{ comment.comment }}
+
+ {% empty %} +

No comments available for this service.

+ {% endfor %} +
+
+
+
+ +
+
+
Assigned Mechanic
+
+ + + + + + + + + + + +
Mechanic Code{{ service.mechanic.mechanic_code }}
Mechanic Email{{ service.mechanic.user.email }}
+
+
+ +
+
Vehicle Information
+
+ + + + + + + +
Vehicle VIN{{ service.vehicle.vin }}
+
+
+ +
+
Owner Information
+
+ + + + + + + + + + + +
Owner Email{{ service.vehicle.owner.email }}
Owner Phone{{ service.vehicle.owner.number }}
+
+
+
+
+
+ + \ No newline at end of file