diff --git a/app/src/api.js b/app/src/api.js index 43467bc8..9294b826 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -825,3 +825,15 @@ export async function uploadFiles(files, destination) { data.append('destination', destination); console.log(data); } + +export async function fetchDecoded(vinNumber) { + const body = {vin_number: vinNumber}; + const response = await apiPost(`${API_URI}/vin_number_decoder`, body); + if (response.status === 200) { + const content = await response.json(); + return content['vin']; + } else if (response.status === 400) { + console.error('Invalid VIN Number'); + return null; + } +} diff --git a/app/src/components/Apps.js b/app/src/components/Apps.js index 2117ac46..e6936ecd 100644 --- a/app/src/components/Apps.js +++ b/app/src/components/Apps.js @@ -1,11 +1,11 @@ import React, {useContext} from 'react'; -import {Divider, SegmentGroup, StatisticLabel, StatisticValue} from "semantic-ui-react"; +import {Divider, Input, SegmentGroup, StatisticLabel, StatisticValue, TableCell, TableRow} from "semantic-ui-react"; import {Link, Route, Routes} from "react-router-dom"; import {decryptOTP, encryptOTP} from "../api"; import {humanFileSize, mimetypeColor, PageContainer, toLocaleString, useTitle} from "./Common"; import {ThemeContext} from "../contexts/contexts"; -import {Button, Header, Loader, Segment, Statistic, StatisticGroup, TextArea} from "./Theme"; -import {useStatistics} from "../hooks/customHooks"; +import {Button, Header, Loader, Segment, Statistic, StatisticGroup, Table, TextArea} from "./Theme"; +import {useStatistics, useVINDecoder} from "../hooks/customHooks"; class Encrypt extends React.Component { constructor(props) { @@ -145,6 +145,57 @@ function OTP() { } +function VINDecoder() { + useTitle('VIN Number Decoder'); + const {t} = useContext(ThemeContext); + + const basicKeys = ['Country', 'Manufacturer', 'Region', 'Years']; + const detailsKeys = ['Body', 'Engine', 'Model', 'Plant', 'Transmission', 'Serial']; + + const {value, setValue, vin} = useVINDecoder(); + console.debug(vin); + + let body =

Enter a VIN number above

; + if (value && !vin) { + body =

VIN number is invalid

; + } + if (vin && vin['country']) { + let details =

No details

; + if (vin['body']) { + details = + {detailsKeys.map(i => + {i} + {vin[i.toLowerCase()] || '(Unknown)'} + )} +
; + } + body = <> + + {basicKeys.map(i => + {i} + {vin[i.toLowerCase()]} + )} +
+
Details
+ {details} + ; + } + + return <> +
VIN Number Decoder
+ setValue(e.target.value)} + /> + +
Decoded
+ {body} + +} + + function StatisticsPage() { useTitle('Statistics'); @@ -251,6 +302,7 @@ export function MoreRoute(props) { }/> }/> + }/> } diff --git a/app/src/components/Nav.js b/app/src/components/Nav.js index fb0c20ab..9d47cb3b 100644 --- a/app/src/components/Nav.js +++ b/app/src/components/Nav.js @@ -17,6 +17,7 @@ const links = [ links: [ {to: '/more/otp', text: 'One Time Pad', end: true}, {to: '/more/statistics', text: 'Statistics', end: true}, + {to: '/more/vin', text: 'VIN Decoder', end: true}, ] }, ]; diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 6ef990d5..cba63f24 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -1,5 +1,6 @@ import {useContext, useEffect, useRef, useState} from "react"; import { + fetchDecoded, fetchDomains, fetchFilesProgress, filesSearch, @@ -928,3 +929,29 @@ export const useUploadFile = () => { return {files, setFiles: handleFilesChange, progresses, destination, setDestination, doClear, doUpload} } + +export const useVINDecoder = (defaultVINNumber = '') => { + const [value, setValue] = useState(defaultVINNumber); + // The response vin from the API. + const [vin, setVin] = useState(null); + + const localFetchDecoded = async () => { + if (!value) { + return; + } + + try { + const vin = await fetchDecoded(value); + setVin(vin); + } catch (e) { + console.error('Failed to decode VIN number'); + setVin(null); + } + } + + useEffect(() => { + localFetchDecoded(); + }, [value]); + + return {value, setValue, vin} +} diff --git a/requirements.txt b/requirements.txt index a49f06bd..a75af6b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,7 @@ sanic-testing==22.6.0 sanic==22.6.2 selenium==4.9.1 srt==3.5.3 +vininfo[cli]==1.7.0 websockets==10.1 webvtt-py==0.4.6 -yt-dlp==2023.3.4 +yt-dlp==2023.3.4 \ No newline at end of file diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index d4aeb682..9e4d6970 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -7,12 +7,15 @@ from http import HTTPStatus from pathlib import Path from typing import Union +import vininfo.exceptions from sanic import Sanic, response, Blueprint, __version__ as sanic_version from sanic.blueprint_group import BlueprintGroup from sanic.request import Request from sanic.response import HTTPResponse from sanic_ext import validate from sanic_ext.extensions.openapi import openapi +from vininfo import Vin +from vininfo.details._base import VinDetails from wrolpi import admin, status, flags, schema from wrolpi import tags @@ -421,6 +424,39 @@ async def post_notify(_: Request, body: schema.NotifyRequest): return response.empty(HTTPStatus.CREATED) +@api_bp.post('/vin_number_decoder') +@validate(schema.VINDecoderRequest) +async def post_vin_number_decoder(_: Request, body: schema.VINDecoderRequest): + try: + vin = Vin(body.vin_number) + except vininfo.exceptions.VininfoException: + return json_response({}, HTTPStatus.BAD_REQUEST) + + def detail_to_json(details: VinDetails, key: str): + if details: + attr = getattr(details, key) + if attr and attr.name: + name = attr.name + if isinstance(name, list): + return ', '.join(str(i) for i in name) + else: + return name + + vin = schema.VIN( + country=vin.country, + manufacturer=vin.manufacturer, + region=vin.region, + years=','.join(map(str, vin.years)), + body=detail_to_json(vin.details, 'body'), + engine=detail_to_json(vin.details, 'engine'), + model=detail_to_json(vin.details, 'model'), + plant=detail_to_json(vin.details, 'plant'), + transmission=detail_to_json(vin.details, 'transmission'), + serial=detail_to_json(vin.details, 'serial'), + ) + return json_response(dict(vin=vin)) + + class CustomJSONEncoder(json.JSONEncoder): def default(self, obj): diff --git a/wrolpi/schema.py b/wrolpi/schema.py index 16d83751..c3c5c82b 100644 --- a/wrolpi/schema.py +++ b/wrolpi/schema.py @@ -82,3 +82,41 @@ class DeleteTagRequest: class NotifyRequest: message: str url: str = None + + +@dataclass +class VINDecoderRequest: + vin_number: str + +@dataclass +class VIN: + country: str + manufacturer: str + region: str + years: str + body: str = None + engine: str = None + model: str = None + plant: str = None + transmission: str = None + serial: str = None + + def __json__(self): + d = dict( + country=self.country, + manufacturer=self.manufacturer, + region=self.region, + years=self.years, + body=self.body, + engine=self.engine, + model=self.model, + plant=self.plant, + transmission=self.transmission, + serial=self.serial, + ) + return d + + +@dataclass +class VINDecoderResponse: + vin: VIN diff --git a/wrolpi/test/test_root_api.py b/wrolpi/test/test_root_api.py index f307590f..7b4c6d9b 100644 --- a/wrolpi/test/test_root_api.py +++ b/wrolpi/test/test_root_api.py @@ -358,3 +358,27 @@ def test_get_downloaders(test_client): def test_get_global_statistics(test_session, test_client): request, response = test_client.get('/api/statistics') assert response.json['global_statistics']['db_size'] > 1 + + +def test_post_vin_number_decoder(test_client): + """A VIN number can be decoded.""" + # Invalid VIN number. + content = dict(vin_number='5N1NJ01CXST00000') + request, response = test_client.post('/api/vin_number_decoder', content=json.dumps(content)) + assert response.status_code == HTTPStatus.BAD_REQUEST + assert response.json == dict() + + # Test VIN from vininfo. + content = dict(vin_number='5N1NJ01CXST000001') + request, response = test_client.post('/api/vin_number_decoder', content=json.dumps(content)) + assert response.status_code == HTTPStatus.OK + assert response.json['vin'] == {'body': 'Sedan, 4-Door, Standard Body Truck', + 'country': 'United States', + 'engine': 'VH45DE', + 'manufacturer': 'Nissan', + 'model': 'Maxima', + 'plant': 'Tochigi, Oppama', + 'region': 'North America', + 'serial': None, + 'transmission': None, + 'years': '1995'}