├── ui
├── tests
│ ├── unit
│ │ ├── __mocks__
│ │ │ └── .keep
│ │ ├── global-teardown.js
│ │ ├── global-setup.js
│ │ └── matchers.js
│ ├── e2e
│ │ ├── support
│ │ │ ├── utils.js
│ │ │ ├── commands.js
│ │ │ └── setup.js
│ │ ├── .eslintrc.js
│ │ ├── specs
│ │ │ ├── home.e2e.js
│ │ │ ├── profile.e2e.js
│ │ │ └── auth.e2e.js
│ │ └── plugins
│ │ │ └── index.js
│ └── mock-api
│ │ ├── index.js
│ │ ├── routes
│ │ ├── users.js
│ │ └── auth.js
│ │ └── resources
│ │ └── users.js
├── .dockerignore
├── .browserslistrc
├── .env
├── .eslintignore
├── src
│ ├── design
│ │ ├── _durations.scss
│ │ ├── _layers.scss
│ │ ├── _colors.scss
│ │ ├── index.scss
│ │ ├── _fonts.scss
│ │ └── _sizes.scss
│ ├── assets
│ │ └── images
│ │ │ └── logo.png
│ ├── app.config.json
│ ├── components
│ │ ├── _base-button.vue
│ │ ├── _base-button.unit.js
│ │ ├── nav-bar.unit.js
│ │ ├── _base-icon.unit.js
│ │ ├── _base-icon.vue
│ │ ├── _globals.js
│ │ ├── nav-bar-routes.unit.js
│ │ ├── _base-input-text.vue
│ │ ├── _base-input-text.unit.js
│ │ ├── nav-bar-routes.vue
│ │ ├── quickEntryDisplay.vue
│ │ ├── mileageChart.vue
│ │ ├── _base-link.vue
│ │ ├── nav-bar.vue
│ │ ├── shareVehicle.vue
│ │ ├── createQuickEntry.vue
│ │ └── _base-link.unit.js
│ ├── router
│ │ ├── views
│ │ │ ├── _404.unit.js
│ │ │ ├── import.unit.js
│ │ │ ├── _loading.unit.js
│ │ │ ├── _timeout.unit.js
│ │ │ ├── import-fuelly.unit.js
│ │ │ ├── import-generic.unit.js
│ │ │ ├── home.unit.js
│ │ │ ├── profile.unit.js
│ │ │ ├── _404.vue
│ │ │ ├── profile.vue
│ │ │ ├── _loading.vue
│ │ │ ├── _timeout.vue
│ │ │ ├── import.vue
│ │ │ ├── login.unit.js
│ │ │ ├── login.vue
│ │ │ ├── siteSettings.vue
│ │ │ └── quickEntries.vue
│ │ └── layouts
│ │ │ ├── main.vue
│ │ │ └── main.unit.js
│ ├── state
│ │ ├── helpers.js
│ │ ├── store.js
│ │ └── modules
│ │ │ ├── utils.js
│ │ │ ├── users.unit.js
│ │ │ ├── users.js
│ │ │ ├── auth.js
│ │ │ ├── index.js
│ │ │ └── auth.unit.js
│ ├── utils
│ │ ├── format-date-relative.js
│ │ ├── format-date.js
│ │ ├── format-date.unit.js
│ │ ├── format-date-relative.unit.js
│ │ ├── dispatch-action-for-all-modules.js
│ │ └── dispatch-action-for-all-modules.unit.js
│ ├── i18n.js
│ ├── app.vue
│ └── main.js
├── cypress.json
├── .prettierignore
├── public
│ ├── hammond.png
│ ├── touch-icon.png
│ └── index.html
├── .postcssrc.js
├── babel.config.js
├── generators
│ └── new
│ │ ├── e2e
│ │ ├── e2e.ejs.t
│ │ └── prompt.js
│ │ ├── module
│ │ ├── module.ejs.t
│ │ ├── prompt.js
│ │ └── unit.ejs.t
│ │ ├── util
│ │ ├── util.ejs.t
│ │ ├── prompt.js
│ │ └── unit.ejs.t
│ │ ├── layout
│ │ ├── prompt.js
│ │ ├── layout.ejs.t
│ │ └── unit.ejs.t
│ │ ├── view
│ │ ├── prompt.js
│ │ ├── unit.ejs.t
│ │ └── view.ejs.t
│ │ └── component
│ │ ├── unit.ejs.t
│ │ ├── component.ejs.t
│ │ └── prompt.js
├── docker-compose.yml
├── Dockerfile
├── docker-dev.dockerfile
├── .markdownlint.yml
├── jsconfig.template.js
├── .prettierrc.js
├── docs
│ ├── production.md
│ ├── editors.md
│ ├── routing.md
│ ├── troubleshooting.md
│ ├── state.md
│ ├── linting.md
│ └── architecture.md
├── .gitignore
├── .vuepress
│ └── config.js
├── lint-staged.config.js
├── .gitattributes
├── vue.config.js
├── aliases.config.js
├── stylelint.config.js
├── jest.config.js
├── .eslintrc.js
└── package.json
├── images
├── users.jpg
├── settings.jpg
├── screenshot.jpg
├── create_fillup.jpg
├── vehicles_add.jpg
├── create_expense.jpg
└── vehicle_detail.jpg
├── .gitignore
├── server
├── .env
├── models
│ ├── files.go
│ ├── errors.go
│ ├── misc.go
│ ├── alert.go
│ ├── import.go
│ ├── auth.go
│ └── report.go
├── internal
│ └── sanitize
│ │ ├── .gitignore
│ │ ├── LICENSE
│ │ └── README.md
├── .gitignore
├── db
│ ├── base.go
│ ├── migrations.go
│ ├── db.go
│ └── enums.go
├── go.mod
├── service
│ ├── miscService.go
│ ├── userService.go
│ ├── genericImportService.go
│ ├── reportService.go
│ ├── importService.go
│ ├── fuellyImportService.go
│ └── drivvoImportService.go
├── Dockerfile
├── controllers
│ ├── reports.go
│ ├── users.go
│ ├── setup.go
│ ├── masters.go
│ ├── import.go
│ └── middlewares.go
├── main.go
└── common
│ └── utils.go
├── docker-compose.yml
├── .github
├── workflows
│ ├── test-go.yml
│ └── hub.yml
└── ISSUE_TEMPLATE
│ └── bug_report.md
├── Screenshots.md
├── Dockerfile
└── docs
└── ubuntu-install.md
/ui/tests/unit/__mocks__/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/ui/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 |
--------------------------------------------------------------------------------
/ui/.browserslistrc:
--------------------------------------------------------------------------------
1 | > 1%
2 | last 2 versions
3 |
--------------------------------------------------------------------------------
/ui/.env:
--------------------------------------------------------------------------------
1 | API_BASE_URL=http://localhost:3000
2 |
--------------------------------------------------------------------------------
/ui/.eslintignore:
--------------------------------------------------------------------------------
1 | /dist/
2 | /tests/unit/coverage/
3 |
--------------------------------------------------------------------------------
/ui/src/design/_durations.scss:
--------------------------------------------------------------------------------
1 | $duration-animation-base: 300ms;
2 |
--------------------------------------------------------------------------------
/ui/cypress.json:
--------------------------------------------------------------------------------
1 | {
2 | "pluginsFile": "tests/e2e/plugins/index.js"
3 | }
4 |
--------------------------------------------------------------------------------
/images/users.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlfHou/hammond/HEAD/images/users.jpg
--------------------------------------------------------------------------------
/ui/.prettierignore:
--------------------------------------------------------------------------------
1 | /node_modules/**
2 | /dist/**
3 | /tests/unit/coverage/**
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Don't track .vscode directory
2 | .vscode
3 | !.vscode/launch.json
4 |
5 |
--------------------------------------------------------------------------------
/images/settings.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlfHou/hammond/HEAD/images/settings.jpg
--------------------------------------------------------------------------------
/images/screenshot.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlfHou/hammond/HEAD/images/screenshot.jpg
--------------------------------------------------------------------------------
/ui/public/hammond.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlfHou/hammond/HEAD/ui/public/hammond.png
--------------------------------------------------------------------------------
/images/create_fillup.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlfHou/hammond/HEAD/images/create_fillup.jpg
--------------------------------------------------------------------------------
/images/vehicles_add.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlfHou/hammond/HEAD/images/vehicles_add.jpg
--------------------------------------------------------------------------------
/ui/.postcssrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | autoprefixer: {},
4 | },
5 | }
6 |
--------------------------------------------------------------------------------
/ui/public/touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlfHou/hammond/HEAD/ui/public/touch-icon.png
--------------------------------------------------------------------------------
/images/create_expense.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlfHou/hammond/HEAD/images/create_expense.jpg
--------------------------------------------------------------------------------
/images/vehicle_detail.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlfHou/hammond/HEAD/images/vehicle_detail.jpg
--------------------------------------------------------------------------------
/server/.env:
--------------------------------------------------------------------------------
1 | CONFIG=.
2 | DATA=./assets
3 | JWT_SECRET="A super strong secret that needs to be changed"
4 |
--------------------------------------------------------------------------------
/ui/src/assets/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlfHou/hammond/HEAD/ui/src/assets/images/logo.png
--------------------------------------------------------------------------------
/ui/src/app.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Hammond",
3 | "description": "Like Clarkson, but better"
4 | }
5 |
--------------------------------------------------------------------------------
/ui/tests/e2e/support/utils.js:
--------------------------------------------------------------------------------
1 | // Returns the Vuex store.
2 | export const getStore = () => cy.window().its('__app__.$store')
3 |
--------------------------------------------------------------------------------
/ui/tests/e2e/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: ['cypress'],
3 | env: {
4 | 'cypress/globals': true,
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/server/models/files.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type CreateQuickEntryModel struct {
4 | Comments string `json:"comments" form:"comments"`
5 | }
6 |
--------------------------------------------------------------------------------
/ui/src/components/_base-button.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/ui/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | // https://github.com/vuejs/vue-cli/tree/master/packages/@vue/babel-preset-app
3 | presets: ['@vue/cli-plugin-babel/preset'],
4 | }
5 |
--------------------------------------------------------------------------------
/ui/generators/new/e2e/e2e.ejs.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: tests/e2e/specs/<%= h.changeCase.kebab(name) %>.e2e.js
3 | ---
4 | describe('<%= h.changeCase.pascal(name) %>', () => {
5 |
6 | })
7 |
--------------------------------------------------------------------------------
/ui/src/router/views/_404.unit.js:
--------------------------------------------------------------------------------
1 | import View404 from './_404.vue'
2 |
3 | describe('@views/404', () => {
4 | it('is a valid view', () => {
5 | expect(View404).toBeAViewComponent()
6 | })
7 | })
8 |
--------------------------------------------------------------------------------
/ui/src/router/views/import.unit.js:
--------------------------------------------------------------------------------
1 | import Import from './import'
2 |
3 | describe('@views/import', () => {
4 | it('is a valid view', () => {
5 | expect(Import).toBeAViewComponent()
6 | })
7 | })
8 |
--------------------------------------------------------------------------------
/ui/src/design/_layers.scss:
--------------------------------------------------------------------------------
1 | $layer-negative-z-index: -1;
2 | $layer-page-z-index: 1;
3 | $layer-dropdown-z-index: 2;
4 | $layer-modal-z-index: 3;
5 | $layer-popover-z-index: 4;
6 | $layer-tooltip-z-index: 5;
7 |
--------------------------------------------------------------------------------
/ui/src/router/views/_loading.unit.js:
--------------------------------------------------------------------------------
1 | import Loading from './_loading.vue'
2 |
3 | describe('@views/loading', () => {
4 | it('is a valid view', () => {
5 | expect(Loading).toBeAViewComponent()
6 | })
7 | })
8 |
--------------------------------------------------------------------------------
/ui/src/router/views/_timeout.unit.js:
--------------------------------------------------------------------------------
1 | import Timeout from './_timeout.vue'
2 |
3 | describe('@views/timeout', () => {
4 | it('is a valid view', () => {
5 | expect(Timeout).toBeAViewComponent()
6 | })
7 | })
8 |
--------------------------------------------------------------------------------
/ui/tests/unit/global-teardown.js:
--------------------------------------------------------------------------------
1 | // Shut down the mock API once all the tests are complete.
2 |
3 | module.exports = () => {
4 | return new Promise((resolve, reject) => {
5 | global.mockApiServer.close(resolve)
6 | })
7 | }
8 |
--------------------------------------------------------------------------------
/ui/src/router/views/import-fuelly.unit.js:
--------------------------------------------------------------------------------
1 | import ImportFuelly from './import-fuelly'
2 |
3 | describe('@views/import-fuelly', () => {
4 | it('is a valid view', () => {
5 | expect(ImportFuelly).toBeAViewComponent()
6 | })
7 | })
8 |
--------------------------------------------------------------------------------
/ui/src/router/views/import-generic.unit.js:
--------------------------------------------------------------------------------
1 | import ImportGeneric from './import-generic'
2 |
3 | describe('@views/import-generic', () => {
4 | it('is a valid view', () => {
5 | expect(ImportGeneric).toBeAViewComponent()
6 | })
7 | })
8 |
--------------------------------------------------------------------------------
/ui/tests/e2e/specs/home.e2e.js:
--------------------------------------------------------------------------------
1 | describe('Home Page', () => {
2 | it('has the correct title and heading', () => {
3 | cy.visit('/')
4 | cy.title().should('equal', 'Home | Hammond')
5 | cy.contains('h1', 'Home Page')
6 | })
7 | })
8 |
--------------------------------------------------------------------------------
/ui/generators/new/module/module.ejs.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: src/state/modules/<%= h.changeCase.kebab(name) %>.js
3 | ---
4 | export const state = {}
5 |
6 | export const getters = {}
7 |
8 | export const mutations = {}
9 |
10 | export const actions = {}
11 |
--------------------------------------------------------------------------------
/server/models/errors.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import "fmt"
4 |
5 | type VehicleAlreadyExistsError struct {
6 | Registration string
7 | }
8 |
9 | func (e *VehicleAlreadyExistsError) Error() string {
10 | return fmt.Sprintf("Vehicle with this url already exists")
11 | }
12 |
--------------------------------------------------------------------------------
/ui/generators/new/util/util.ejs.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: "src/utils/<%= h.changeCase.kebab(name) %>.js"
3 | ---
4 | <%
5 | const fileName = h.changeCase.kebab(name)
6 | const importName = h.changeCase.camel(fileName)
7 | %>export default function <%= importName %>() {
8 | return 'hello'
9 | }
10 |
--------------------------------------------------------------------------------
/ui/generators/new/e2e/prompt.js:
--------------------------------------------------------------------------------
1 | module.exports = [
2 | {
3 | type: 'input',
4 | name: 'name',
5 | message: 'Name:',
6 | validate(value) {
7 | if (!value.length) {
8 | return 'Components must have a name.'
9 | }
10 | return true
11 | },
12 | },
13 | ]
14 |
--------------------------------------------------------------------------------
/ui/generators/new/module/prompt.js:
--------------------------------------------------------------------------------
1 | module.exports = [
2 | {
3 | type: 'input',
4 | name: 'name',
5 | message: 'Name:',
6 | validate(value) {
7 | if (!value.length) {
8 | return 'Vuex modules must have a name.'
9 | }
10 | return true
11 | },
12 | },
13 | ]
14 |
--------------------------------------------------------------------------------
/ui/generators/new/util/prompt.js:
--------------------------------------------------------------------------------
1 | module.exports = [
2 | {
3 | type: 'input',
4 | name: 'name',
5 | message: 'Name:',
6 | validate(value) {
7 | if (!value.length) {
8 | return 'Utility functions must have a name.'
9 | }
10 | return true
11 | },
12 | },
13 | ]
14 |
--------------------------------------------------------------------------------
/ui/src/router/layouts/main.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
14 |
15 |
--------------------------------------------------------------------------------
/ui/generators/new/layout/prompt.js:
--------------------------------------------------------------------------------
1 | module.exports = [
2 | {
3 | type: 'input',
4 | name: 'name',
5 | message: 'Name:',
6 | validate(value) {
7 | if (!value.length) {
8 | return 'Layout components must have a name.'
9 | }
10 | return true
11 | },
12 | },
13 | ]
14 |
--------------------------------------------------------------------------------
/ui/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | volumes:
4 | dependencies:
5 |
6 | services:
7 | dev:
8 | build:
9 | context: .
10 | dockerfile: ./docker-dev.dockerfile
11 | ports:
12 | - 8080:8080
13 | volumes:
14 | - .:/app
15 | - dependencies:/app/node_modules
16 | tty: true
17 |
--------------------------------------------------------------------------------
/ui/src/state/helpers.js:
--------------------------------------------------------------------------------
1 | import { mapState, mapGetters, mapActions } from 'vuex'
2 |
3 | export const authComputed = {
4 | ...mapState('auth', {
5 | currentUser: (state) => state.currentUser,
6 | }),
7 | ...mapGetters('auth', ['loggedIn']),
8 | }
9 |
10 | export const authMethods = mapActions('auth', ['logIn', 'logOut'])
11 |
--------------------------------------------------------------------------------
/ui/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:latest as build-stage
2 | WORKDIR /app
3 | COPY package*.json ./
4 | RUN npm install
5 | COPY . .
6 | RUN npm run build
7 |
8 | # production stage
9 | FROM nginx:stable-alpine as production-stage
10 | COPY --from=build-stage /app/dist /usr/share/nginx/html
11 | EXPOSE 80
12 | CMD ["nginx", "-g", "daemon off;"]
13 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "2.1"
2 | services:
3 | hammond:
4 | image: alfhou/hammond
5 | container_name: hammond
6 | environment:
7 | - JWT_SECRET=somethingverystrong
8 | volumes:
9 | - /path/to/config:/config
10 | - /path/to/data:/assets
11 | ports:
12 | - 3000:3000
13 | restart: unless-stopped
14 |
--------------------------------------------------------------------------------
/ui/src/router/views/home.unit.js:
--------------------------------------------------------------------------------
1 | import Home from './home.vue'
2 |
3 | describe('@views/home', () => {
4 | it('is a valid view', () => {
5 | expect(Home).toBeAViewComponent()
6 | })
7 |
8 | it('renders an element', () => {
9 | const { element } = shallowMountView(Home)
10 | expect(element.textContent).toContain('Home Page')
11 | })
12 | })
13 |
--------------------------------------------------------------------------------
/server/internal/sanitize/.gitignore:
--------------------------------------------------------------------------------
1 | # Compiled Object files, Static and Dynamic libs (Shared Objects)
2 | *.o
3 | *.a
4 | *.so
5 |
6 | # Folders
7 | _obj
8 | _test
9 |
10 | # Architecture specific extensions/prefixes
11 | *.[568vq]
12 | [568vq].out
13 |
14 | *.cgo1.go
15 | *.cgo2.c
16 | _cgo_defun.c
17 | _cgo_gotypes.go
18 | _cgo_export.*
19 |
20 | _testmain.go
21 |
22 | *.exe
23 |
--------------------------------------------------------------------------------
/ui/src/utils/format-date-relative.js:
--------------------------------------------------------------------------------
1 | // https://date-fns.org/docs/formatDistance
2 | import formatDistance from 'date-fns/formatDistance'
3 | // https://date-fns.org/docs/isToday
4 | import isToday from 'date-fns/isToday'
5 |
6 | export default function formatDateRelative(fromDate, toDate = new Date()) {
7 | return formatDistance(fromDate, toDate) + (isToday(toDate) ? ' ago' : '')
8 | }
9 |
--------------------------------------------------------------------------------
/ui/tests/unit/global-setup.js:
--------------------------------------------------------------------------------
1 | const app = require('express')()
2 |
3 | app.use((request, response, next) => {
4 | response.header('Access-Control-Allow-Origin', '*')
5 | next()
6 | })
7 |
8 | require('../mock-api')(app)
9 |
10 | module.exports = () => {
11 | return new Promise((resolve, reject) => {
12 | global.mockApiServer = app.listen(process.env.MOCK_API_PORT, resolve)
13 | })
14 | }
15 |
--------------------------------------------------------------------------------
/ui/src/router/layouts/main.unit.js:
--------------------------------------------------------------------------------
1 | import MainLayout from './main.vue'
2 |
3 | describe('@layouts/main.vue', () => {
4 | it('renders its content', () => {
5 | const slotContent = '
Hello!
'
6 | const { element } = shallowMount(MainLayout, {
7 | slots: {
8 | default: slotContent,
9 | },
10 | })
11 | expect(element.innerHTML).toContain(slotContent)
12 | })
13 | })
14 |
--------------------------------------------------------------------------------
/ui/generators/new/view/prompt.js:
--------------------------------------------------------------------------------
1 | module.exports = [
2 | {
3 | type: 'input',
4 | name: 'name',
5 | message: 'Name:',
6 | validate(value) {
7 | if (!value.length) {
8 | return 'View components must have a name.'
9 | }
10 | return true
11 | },
12 | },
13 | {
14 | type: 'confirm',
15 | name: 'useStyles',
16 | message: 'Add
19 |
--------------------------------------------------------------------------------
/ui/.markdownlint.yml:
--------------------------------------------------------------------------------
1 | default: true
2 |
3 | # ===
4 | # Rule customizations for markdownlint go here
5 | # https://github.com/DavidAnson/markdownlint/blob/master/doc/Rules.md
6 | # ===
7 |
8 | # Disable line length restrictions, because editor soft-wrapping is being
9 | # used instead.
10 | line-length: false
11 |
12 | # ===
13 | # Prettier overrides
14 | # ===
15 |
16 | no-multiple-blanks: false
17 | list-marker-space: false
18 |
--------------------------------------------------------------------------------
/ui/src/components/_base-button.unit.js:
--------------------------------------------------------------------------------
1 | import BaseButton from './_base-button.vue'
2 |
3 | describe('@components/_base-button', () => {
4 | it('renders its content', () => {
5 | const slotContent = 'foo'
6 | const { element } = shallowMount(BaseButton, {
7 | slots: {
8 | default: slotContent,
9 | },
10 | })
11 | expect(element.innerHTML).toContain(slotContent)
12 | })
13 | })
14 |
--------------------------------------------------------------------------------
/ui/generators/new/view/unit.ejs.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: "src/router/views/<%= h.changeCase.kebab(name) %>.unit.js"
3 | ---
4 | <%
5 | const fileName = h.changeCase.kebab(name)
6 | const importName = h.changeCase.pascal(fileName)
7 | %>import <%= importName %> from './<%= fileName %>'
8 |
9 | describe('@views/<%= fileName %>', () => {
10 | it('is a valid view', () => {
11 | expect(<%= importName %>).toBeAViewComponent()
12 | })
13 | })
14 |
--------------------------------------------------------------------------------
/ui/generators/new/util/unit.ejs.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: "src/utils/<%= h.changeCase.kebab(name) %>.unit.js"
3 | ---
4 | <%
5 | const fileName = h.changeCase.kebab(name)
6 | const importName = h.changeCase.camel(fileName)
7 | %>import <%= importName %> from './<%= fileName %>'
8 |
9 | describe('@utils/<%= fileName %>', () => {
10 | it('says hello', () => {
11 | const result = <%= importName %>()
12 | expect(result).toEqual('hello')
13 | })
14 | })
15 |
--------------------------------------------------------------------------------
/ui/jsconfig.template.js:
--------------------------------------------------------------------------------
1 | // This is a template for a jsconfig.json file which will be
2 | // generated when starting the dev server or a build.
3 |
4 | module.exports = {
5 | baseUrl: '.',
6 | include: ['src/**/*', 'tests/**/*'],
7 | compilerOptions: {
8 | baseUrl: '.',
9 | target: 'esnext',
10 | module: 'es2015',
11 | // ...
12 | // `paths` will be automatically generated using aliases.config.js
13 | // ...
14 | },
15 | }
16 |
--------------------------------------------------------------------------------
/.github/workflows/test-go.yml:
--------------------------------------------------------------------------------
1 | on: [push, pull_request]
2 | name: Test server
3 | jobs:
4 | test:
5 | strategy:
6 | matrix:
7 | go-version: [1.17.x, 1.18.x]
8 | os: [ubuntu-latest]
9 | runs-on: ${{ matrix.os }}
10 | steps:
11 | - uses: actions/setup-go@v3
12 | with:
13 | go-version: ${{ matrix.go-version }}
14 | - uses: actions/checkout@v3
15 | - run: go test ./...
16 | working-directory: server
17 |
--------------------------------------------------------------------------------
/ui/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | arrowParens: 'always',
3 | bracketSpacing: true,
4 | endOfLine: 'lf',
5 | htmlWhitespaceSensitivity: 'strict',
6 | jsxBracketSameLine: false,
7 | jsxSingleQuote: true,
8 | printWidth: 150,
9 | proseWrap: 'never',
10 | quoteProps: 'as-needed',
11 | semi: false,
12 | singleQuote: true,
13 | tabWidth: 2,
14 | trailingComma: 'es5',
15 | useTabs: false,
16 | vueIndentScriptAndStyle: false,
17 | }
18 |
--------------------------------------------------------------------------------
/ui/tests/mock-api/index.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const path = require('path')
3 | const bodyParser = require('body-parser')
4 |
5 | module.exports = (app) => {
6 | app.use(bodyParser.json())
7 | // Register all routes inside tests/mock-api/routes.
8 | fs.readdirSync(path.join(__dirname, 'routes')).forEach((routeFileName) => {
9 | if (/\.js$/.test(routeFileName)) {
10 | require(`./routes/${routeFileName}`)(app)
11 | }
12 | })
13 | }
14 |
--------------------------------------------------------------------------------
/ui/generators/new/module/unit.ejs.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: src/state/modules/<%= h.changeCase.kebab(name) %>.unit.js
3 | ---
4 | <%
5 | const fileName = h.changeCase.kebab(name)
6 | const importName = h.changeCase.camel(fileName) + 'Module'
7 | %>import * as <%= importName %> from './<%= fileName %>'
8 |
9 | describe('@state/modules/<%= fileName %>', () => {
10 | it('exports a valid Vuex module', () => {
11 | expect(<%= importName %>).toBeAVuexModule()
12 | })
13 | })
14 |
--------------------------------------------------------------------------------
/ui/src/design/_colors.scss:
--------------------------------------------------------------------------------
1 | // CONTENT
2 | $color-body-bg: #f9f7f5;
3 | $color-text: #444;
4 | $color-heading-text: #35495e;
5 |
6 | // LINKS
7 | $color-link-text: #39a275;
8 | $color-link-text-active: $color-text;
9 |
10 | // INPUTS
11 | $color-input-border: lighten($color-heading-text, 50%);
12 |
13 | // BUTTONS
14 | $color-button-bg: $color-link-text;
15 | $color-button-disabled-bg: darken(desaturate($color-button-bg, 20%), 10%);
16 | $color-button-text: white;
17 |
--------------------------------------------------------------------------------
/ui/src/utils/format-date.js:
--------------------------------------------------------------------------------
1 | // https://date-fns.org/docs/format
2 | import format from 'date-fns/format'
3 | import parseISO from 'date-fns/parseISO'
4 |
5 | export default function formatDate(date) {
6 | return format(date, 'MMM do, yyyy')
7 | }
8 |
9 | export function parseAndFormatDate(date) {
10 | return format(parseISO(date), 'MMM dd, yyyy')
11 | }
12 |
13 | export function parseAndFormatDateTime(date) {
14 | return format(parseISO(date), 'MMM dd, yyyy hh:mm aa')
15 | }
16 |
--------------------------------------------------------------------------------
/server/models/misc.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import "hammond/db"
4 |
5 | type UpdateSettingModel struct {
6 | Currency string `json:"currency" form:"currency" query:"currency"`
7 | DateFormat string `json:"dateFormat" form:"dateFormat" query:"dateFormat"`
8 | DistanceUnit *db.DistanceUnit `json:"distanceUnit" form:"distanceUnit" query:"distanceUnit" `
9 | }
10 |
11 | type ClarksonMigrationModel struct {
12 | Url string `json:"url" form:"url" query:"url"`
13 | }
14 |
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 | *.db
14 |
15 | # MS VSCode
16 | .vscode
17 | !.vscode/launch.json
18 | __debug_bin
19 |
20 | # Dependency directories (remove the comment below to include it)
21 | # vendor/
22 | assets/*
23 | keys/*
24 | backups/*
25 | nodemon.json
26 | dist/*
27 |
--------------------------------------------------------------------------------
/ui/docs/production.md:
--------------------------------------------------------------------------------
1 | # Building and deploying to production
2 |
3 | - [From the terminal](#from-the-terminal)
4 | - [From Circle CI](#from-circle-ci)
5 |
6 | ## From the terminal
7 |
8 | ```bash
9 | # Build for production with minification
10 | yarn build
11 | ```
12 |
13 | This results in your compiled application in a `dist` directory.
14 |
15 | ## From Circle CI
16 |
17 | Update `.circleci/config.yml` to automatically deploy to staging and/or production on a successful build. See comments in that file for details.
18 |
--------------------------------------------------------------------------------
/ui/src/router/views/profile.unit.js:
--------------------------------------------------------------------------------
1 | import Profile from './profile.vue'
2 |
3 | describe('@views/profile', () => {
4 | it('is a valid view', () => {
5 | expect(Profile).toBeAViewComponentUsing({ user: { name: '' } })
6 | })
7 |
8 | it(`includes the provided user's name`, () => {
9 | const { element } = shallowMountView(Profile, {
10 | propsData: {
11 | user: { name: 'My Name' },
12 | },
13 | })
14 |
15 | expect(element.textContent).toMatch(/My Name\s+Profile/)
16 | })
17 | })
18 |
--------------------------------------------------------------------------------
/server/db/base.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "time"
5 |
6 | uuid "github.com/satori/go.uuid"
7 | "gorm.io/gorm"
8 | )
9 |
10 | //Base is
11 | type Base struct {
12 | ID string `sql:"type:uuid;primary_key" json:"id"`
13 | CreatedAt time.Time `json:"createdAt"`
14 | UpdatedAt time.Time `json:"updatedAt"`
15 | DeletedAt *time.Time `gorm:"index" json:"deletedAt"`
16 | }
17 |
18 | //BeforeCreate
19 | func (base *Base) BeforeCreate(tx *gorm.DB) error {
20 | tx.Statement.SetColumn("ID", uuid.NewV4().String())
21 | return nil
22 | }
23 |
--------------------------------------------------------------------------------
/ui/.gitignore:
--------------------------------------------------------------------------------
1 | # OS Files
2 | .DS_Store
3 | Thumbs.db
4 |
5 | # Dependencies
6 | node_modules/
7 |
8 | # Dev/Build Artifacts
9 | /dist/
10 | /tests/e2e/videos/
11 | /tests/e2e/screenshots/
12 | /tests/unit/coverage/
13 | jsconfig.json
14 |
15 | # Local Env Files
16 | .env.local
17 | .env.*.local
18 |
19 | # Log Files
20 | *.log
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | # Unconfigured Editors
26 | .idea
27 | *.suo
28 | *.ntvs*
29 | *.njsproj
30 | *.sln
31 | *.sw*
32 |
33 | #Vs code files
34 | .vscode
35 | !.vscode/launch.json
36 |
37 |
--------------------------------------------------------------------------------
/ui/.vuepress/config.js:
--------------------------------------------------------------------------------
1 | const appConfig = require('../src/app.config')
2 |
3 | module.exports = {
4 | title: appConfig.title + ' Docs',
5 | description: appConfig.description,
6 | themeConfig: {
7 | sidebar: [
8 | ['/', 'Introduction'],
9 | '/docs/development',
10 | '/docs/architecture',
11 | '/docs/tech',
12 | '/docs/routing',
13 | '/docs/state',
14 | '/docs/tests',
15 | '/docs/linting',
16 | '/docs/editors',
17 | '/docs/production',
18 | '/docs/troubleshooting',
19 | ],
20 | },
21 | }
22 |
--------------------------------------------------------------------------------
/ui/tests/e2e/support/commands.js:
--------------------------------------------------------------------------------
1 | // Create custom Cypress commands and overwrite existing ones.
2 | // https://on.cypress.io/custom-commands
3 |
4 | import { getStore } from './utils'
5 |
6 | Cypress.Commands.add(
7 | 'logIn',
8 | ({ username = 'admin', password = 'password' } = {}) => {
9 | // Manually log the user in
10 | cy.location('pathname').then((pathname) => {
11 | if (pathname === 'blank') {
12 | cy.visit('/')
13 | }
14 | })
15 | getStore().then((store) =>
16 | store.dispatch('auth/logIn', { username, password })
17 | )
18 | }
19 | )
20 |
--------------------------------------------------------------------------------
/Screenshots.md:
--------------------------------------------------------------------------------
1 | ## Home Page / Summary
2 |
3 | ![Product Name Screen Shot][product-screenshot]
4 |
5 | ## Create Vehicle
6 |
7 | 
8 |
9 | ## Vehicle Detail
10 |
11 | 
12 |
13 | ## Create Fillup
14 |
15 | 
16 |
17 | ## Create Expense
18 |
19 | 
20 |
21 | ## User Management
22 |
23 | 
24 |
25 | ## Settings
26 |
27 | 
28 |
29 | [product-screenshot]: images/screenshot.jpg
30 |
--------------------------------------------------------------------------------
/ui/generators/new/component/unit.ejs.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: "src/components/<%= h.changeCase.kebab(name).toLowerCase().slice(0, 5) === 'base-' ? '_' : '' %><%= h.changeCase.kebab(name) %>.unit.js"
3 | ---
4 | <%
5 | let fileName = h.changeCase.kebab(name).toLowerCase()
6 | const importName = h.changeCase.pascal(fileName)
7 | if (fileName.slice(0, 5) === 'base-') {
8 | fileName = '_' + fileName
9 | }
10 | %>import <%= importName %> from './<%= fileName %>'
11 |
12 | describe('@components/<%= fileName %>', () => {
13 | it('exports a valid component', () => {
14 | expect(<%= importName %>).toBeAComponent()
15 | })
16 | })
17 |
--------------------------------------------------------------------------------
/ui/lint-staged.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | '*.js': ['yarn lint:eslint', 'yarn lint:prettier', 'yarn test:unit:file'],
3 | '{!(package)*.json,*.code-snippets,.!(browserslist)*rc}': [
4 | 'yarn lint:prettier --parser json',
5 | ],
6 | 'package.json': ['yarn lint:prettier'],
7 | '*.vue': [
8 | 'yarn lint:eslint',
9 | 'yarn lint:stylelint',
10 | 'yarn lint:prettier',
11 | 'yarn test:unit:file',
12 | ],
13 | '*.scss': ['yarn lint:stylelint', 'yarn lint:prettier'],
14 | '*.md': ['yarn lint:markdownlint', 'yarn lint:prettier'],
15 | '*.{png,jpeg,jpg,gif,svg}': ['imagemin-lint-staged'],
16 | }
17 |
--------------------------------------------------------------------------------
/ui/generators/new/layout/unit.ejs.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: "src/router/layouts/<%= h.changeCase.kebab(name) %>.unit.js"
3 | ---
4 | <%
5 | const fileName = h.changeCase.kebab(name)
6 | const importName = h.changeCase.pascal(fileName) + 'Layout'
7 | %>import <%= importName %> from './<%= fileName %>'
8 |
9 | describe('@layouts/<%= fileName %>', () => {
10 | it('renders its content', () => {
11 | const slotContent = 'Hello!
'
12 | const { element } = shallowMount(<%= importName %>, {
13 | slots: {
14 | default: slotContent,
15 | },
16 | })
17 | expect(element.innerHTML).toContain(slotContent)
18 | })
19 | })
20 |
--------------------------------------------------------------------------------
/ui/src/state/store.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import Vuex from 'vuex'
3 | import dispatchActionForAllModules from '@utils/dispatch-action-for-all-modules'
4 |
5 | import modules from './modules'
6 |
7 | Vue.use(Vuex)
8 |
9 | const store = new Vuex.Store({
10 | modules,
11 | // Enable strict mode in development to get a warning
12 | // when mutating state outside of a mutation.
13 | // https://vuex.vuejs.org/guide/strict.html
14 | strict: process.env.NODE_ENV !== 'production',
15 | })
16 |
17 | export default store
18 |
19 | // Automatically run the `init` action for every module,
20 | // if one exists.
21 | dispatchActionForAllModules('init')
22 |
--------------------------------------------------------------------------------
/ui/src/design/index.scss:
--------------------------------------------------------------------------------
1 | @import 'colors';
2 | @import 'durations';
3 | @import 'fonts';
4 | @import 'layers';
5 | @import 'sizes';
6 | @import 'typography';
7 |
8 | :export {
9 | // Any values that need to be accessible from JavaScript
10 | // outside of a Vue component can be defined here, prefixed
11 | // with `global-` to avoid conflicts with classes. For
12 | // example:
13 | //
14 | // global-grid-padding: $size-grid-padding;
15 | //
16 | // Then in a JavaScript file, you can import this object
17 | // as you would normally with:
18 | //
19 | // import design from '@design'
20 | //
21 | // console.log(design['global-grid-padding'])
22 | }
23 |
--------------------------------------------------------------------------------
/ui/src/design/_fonts.scss:
--------------------------------------------------------------------------------
1 | $system-default-font-family: -apple-system, 'BlinkMacSystemFont', 'Segoe UI',
2 | 'Helvetica', 'Arial', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
3 | 'Segoe UI Symbol';
4 |
5 | $heading-font-family: $system-default-font-family;
6 | $heading-font-weight: 600;
7 |
8 | $content-font-family: $system-default-font-family;
9 | $content-font-weight: 400;
10 |
11 | %font-heading {
12 | font-family: $heading-font-family;
13 | font-weight: $heading-font-weight;
14 | color: $color-heading-text;
15 | }
16 |
17 | %font-content {
18 | font-family: $content-font-family;
19 | font-weight: $content-font-weight;
20 | color: $color-text;
21 | }
22 |
--------------------------------------------------------------------------------
/ui/generators/new/component/component.ejs.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: "src/components/<%= h.changeCase.kebab(name).toLowerCase().slice(0, 5) === 'base-' ? '_' : '' %><%= h.changeCase.kebab(name) %>.vue"
3 | ---
4 | <%
5 | if (blocks.indexOf('script') !== -1) {
6 | %>
14 | <%
15 | }
16 |
17 | if (blocks.indexOf('template') !== -1) {
18 | %>
19 |
20 |
21 |
22 | <%
23 | }
24 |
25 | if (blocks.indexOf('style') !== -1) {
26 | %>
27 | <%
30 | }
31 | %>
32 |
--------------------------------------------------------------------------------
/ui/tests/e2e/support/setup.js:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/index.js is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 |
16 | // Import commands.js using ES2015 syntax:
17 | import './commands'
18 |
--------------------------------------------------------------------------------
/ui/src/design/_sizes.scss:
--------------------------------------------------------------------------------
1 | // GRID
2 | $size-grid-padding: 1.3rem;
3 |
4 | // CONTENT
5 | $size-content-width-max: 50rem;
6 | $size-content-width-min: 25rem;
7 |
8 | // INPUTS
9 | $size-input-padding-vertical: 0.75em;
10 | $size-input-padding-horizontal: 1em;
11 | $size-input-padding: $size-input-padding-vertical $size-input-padding-horizontal;
12 | $size-input-border: 1px;
13 | $size-input-border-radius: calc((1em + $size-input-padding-vertical * 2) / 10);
14 |
15 | // BUTTONS
16 | $size-button-padding-vertical: calc($size-grid-padding / 2);
17 | $size-button-padding-horizontal: calc($size-grid-padding / 1.5);
18 | $size-button-padding: $size-button-padding-vertical
19 | $size-button-padding-horizontal;
20 |
--------------------------------------------------------------------------------
/ui/src/router/views/_404.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
22 | 404
23 |
24 | {{ resource }}
25 |
26 | {{ $t('notfound') }}
27 |
28 |
29 |
30 |
31 |
36 |
--------------------------------------------------------------------------------
/ui/src/router/views/profile.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
28 |
29 |
30 | {{ user.name }}
31 | {{ $t('profile') }}
32 |
33 | {{ user }}
34 |
35 |
36 |
--------------------------------------------------------------------------------
/ui/src/i18n.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import VueI18n from 'vue-i18n';
3 |
4 | Vue.use(VueI18n);
5 |
6 | function loadLocaleMessages () {
7 | const locales = require.context('./locales', true, /[A-Za-z0-9-_,\s]+\.json$/i)
8 | const messages = {}
9 | locales.keys().forEach(key => {
10 | const matched = key.match(/([A-Za-z0-9-_]+)\./i)
11 | if (matched && matched.length > 1) {
12 | const locale = matched[1]
13 | messages[locale] = locales(key)
14 | }
15 | })
16 | return messages
17 | }
18 |
19 | const i18n = new VueI18n({
20 | locale: navigator.language.split('-')[0] || 'en',
21 | fallbackLocale: 'en',
22 | messages: loadLocaleMessages()
23 | });
24 |
25 | export default i18n;
--------------------------------------------------------------------------------
/ui/generators/new/view/view.ejs.t:
--------------------------------------------------------------------------------
1 | ---
2 | to: "src/router/views/<%= h.changeCase.kebab(name) %>.vue"
3 | ---
4 | <%
5 | const fileName = h.changeCase.kebab(name)
6 | const importName = h.changeCase.pascal(fileName)
7 | const titleName = h.changeCase.title(name)
8 | %>
19 |
20 |
21 |
22 | <%= titleName %>
23 |
24 |
25 | <%
26 |
27 | if (useStyles) { %>
28 |
31 | <% } %>
32 |
--------------------------------------------------------------------------------
/ui/tests/mock-api/routes/users.js:
--------------------------------------------------------------------------------
1 | const Users = require('../resources/users')
2 |
3 | module.exports = (app) => {
4 | app.get('/api/users/:username', (request, response) => {
5 | const currentUser = Users.findBy('token', request.headers.authorization)
6 |
7 | if (!currentUser) {
8 | return response.status(401).json({
9 | message:
10 | 'The token is either invalid or has expired. Please log in again.',
11 | })
12 | }
13 |
14 | const matchedUser = Users.findBy('username', request.params.username)
15 |
16 | if (!matchedUser) {
17 | return response.status(400).json({
18 | message: 'No user with this name was found.',
19 | })
20 | }
21 |
22 | response.json(matchedUser)
23 | })
24 | }
25 |
--------------------------------------------------------------------------------
/ui/src/utils/format-date.unit.js:
--------------------------------------------------------------------------------
1 | import formatDate from './format-date'
2 |
3 | describe('@utils/format-date', () => {
4 | it('correctly compares dates years apart', () => {
5 | const date = new Date(2002, 5, 1)
6 | const formattedDate = formatDate(date)
7 | expect(formattedDate).toEqual('Jun 1st, 2002')
8 | })
9 |
10 | it('correctly compares dates months apart', () => {
11 | const date = new Date(2017, 8, 1)
12 | const formattedDate = formatDate(date)
13 | expect(formattedDate).toEqual('Sep 1st, 2017')
14 | })
15 |
16 | it('correctly compares dates days apart', () => {
17 | const date = new Date(2017, 11, 11)
18 | const formattedDate = formatDate(date)
19 | expect(formattedDate).toEqual('Dec 11th, 2017')
20 | })
21 | })
22 |
--------------------------------------------------------------------------------
/server/go.mod:
--------------------------------------------------------------------------------
1 | module hammond
2 |
3 | go 1.16
4 |
5 | require (
6 | github.com/dgrijalva/jwt-go v3.2.0+incompatible
7 | github.com/gin-contrib/location v0.0.2
8 | github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19 // indirect
9 | github.com/gin-gonic/gin v1.7.1
10 | github.com/go-playground/validator/v10 v10.4.1
11 | github.com/jasonlvhit/gocron v0.0.1
12 | github.com/joho/godotenv v1.3.0
13 | github.com/leekchan/accounting v1.0.0 // indirect
14 | github.com/satori/go.uuid v1.2.0
15 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
16 | golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1
17 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
18 | gorm.io/driver/mysql v1.0.5
19 | gorm.io/driver/sqlite v1.1.4
20 | gorm.io/gorm v1.21.3
21 | )
22 |
--------------------------------------------------------------------------------
/ui/src/components/nav-bar.unit.js:
--------------------------------------------------------------------------------
1 | import NavBar from './nav-bar.vue'
2 |
3 | describe('@components/nav-bar', () => {
4 | it(`displays the user's name in the profile link`, () => {
5 | const { vm } = shallowMount(
6 | NavBar,
7 | createComponentMocks({
8 | store: {
9 | auth: {
10 | state: {
11 | currentUser: {
12 | name: 'My Name',
13 | },
14 | },
15 | getters: {
16 | loggedIn: () => true,
17 | },
18 | },
19 | },
20 | })
21 | )
22 |
23 | const profileRoute = vm.loggedInNavRoutes.find(
24 | (route) => route.name === 'profile'
25 | )
26 | expect(profileRoute.title()).toEqual('Logged in as My Name')
27 | })
28 | })
29 |
--------------------------------------------------------------------------------
/server/models/alert.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "time"
5 |
6 | "hammond/db"
7 | )
8 |
9 | type CreateAlertModel struct {
10 | Comments string `json:"comments"`
11 | Title string `json:"title"`
12 | StartDate time.Time `json:"date"`
13 | StartOdoReading int `json:"startOdoReading"`
14 | DistanceUnit *db.DistanceUnit `json:"distanceUnit"`
15 | AlertFrequency *db.AlertFrequency `json:"alertFrequency"`
16 | OdoFrequency int `json:"odoFrequency"`
17 | DayFrequency int `json:"dayFrequency"`
18 | AlertAllUsers bool `json:"alertAllUsers"`
19 | IsActive bool `json:"isActive"`
20 | AlertType *db.AlertType `json:"alertType"`
21 | }
22 |
--------------------------------------------------------------------------------
/ui/.gitattributes:
--------------------------------------------------------------------------------
1 | # Fix end-of-lines in Git versions older than 2.10
2 | # https://github.com/git/git/blob/master/Documentation/RelNotes/2.10.0.txt#L248
3 | * text=auto eol=lf
4 |
5 | # ===
6 | # Binary Files (don't diff, don't fix line endings)
7 | # ===
8 |
9 | # Images
10 | *.png binary
11 | *.jpg binary
12 | *.jpeg binary
13 | *.gif binary
14 | *.ico binary
15 | *.tiff binary
16 |
17 | # Fonts
18 | *.oft binary
19 | *.ttf binary
20 | *.eot binary
21 | *.woff binary
22 | *.woff2 binary
23 |
24 | # Videos
25 | *.mov binary
26 | *.mp4 binary
27 | *.webm binary
28 | *.ogg binary
29 | *.mpg binary
30 | *.3gp binary
31 | *.avi binary
32 | *.wmv binary
33 | *.flv binary
34 | *.asf binary
35 |
36 | # Audio
37 | *.mp3 binary
38 | *.wav binary
39 | *.flac binary
40 |
41 | # Compressed
42 | *.gz binary
43 | *.zip binary
44 | *.7z binary
45 |
--------------------------------------------------------------------------------
/server/service/miscService.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "hammond/db"
5 | )
6 |
7 | func CanInitializeSystem() (bool, error) {
8 | return db.CanInitializeSystem()
9 | }
10 |
11 | func UpdateSettings(currency string, distanceUnit db.DistanceUnit) error {
12 | setting := db.GetOrCreateSetting()
13 | setting.Currency = currency
14 | setting.DistanceUnit = distanceUnit
15 | return db.UpdateSettings(setting)
16 | }
17 | func UpdateUserSettings(userId, currency string, distanceUnit db.DistanceUnit, dateFormat string) error {
18 | user, err := db.GetUserById(userId)
19 | if err != nil {
20 | return err
21 | }
22 |
23 | user.Currency = currency
24 | user.DistanceUnit = distanceUnit
25 | user.DateFormat = dateFormat
26 | return db.UpdateUser(user)
27 | }
28 |
29 | func GetSettings() *db.Setting {
30 | return db.GetOrCreateSetting()
31 | }
32 |
--------------------------------------------------------------------------------
/ui/src/components/_base-icon.unit.js:
--------------------------------------------------------------------------------
1 | import BaseIcon from './_base-icon.vue'
2 |
3 | describe('@components/_base-icon', () => {
4 | it('renders a font-awesome icon', () => {
5 | const { element } = mount(BaseIcon, {
6 | propsData: {
7 | name: 'sync',
8 | },
9 | })
10 |
11 | expect(element.tagName).toEqual('svg')
12 | expect(element.classList).toContain('svg-inline--fa', 'fa-sync', 'fa-w-16')
13 | })
14 |
15 | it('renders a custom icon', () => {
16 | const { element } = shallowMount(BaseIcon, {
17 | ...createComponentMocks({
18 | style: {
19 | iconCustomSomeIcon: 'generated-class-name',
20 | },
21 | }),
22 | propsData: {
23 | source: 'custom',
24 | name: 'some-icon',
25 | },
26 | })
27 |
28 | expect(element.className).toEqual('generated-class-name')
29 | })
30 | })
31 |
--------------------------------------------------------------------------------
/ui/src/router/views/_loading.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
40 |
--------------------------------------------------------------------------------
/ui/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | <%= webpackConfig.name %>
10 |
11 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/server/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG GO_VERSION=1.15.2
2 |
3 | FROM golang:${GO_VERSION}-alpine AS builder
4 |
5 | RUN apk update && apk add alpine-sdk git && rm -rf /var/cache/apk/*
6 |
7 | RUN mkdir -p /api
8 | WORKDIR /api
9 |
10 | COPY go.mod .
11 | COPY go.sum .
12 | RUN go mod download
13 |
14 | COPY . .
15 | RUN go build -o ./app ./main.go
16 |
17 | FROM alpine:latest
18 |
19 | LABEL org.opencontainers.image.source="https://github.com/alfhou/hammond"
20 |
21 | ENV CONFIG=/config
22 | ENV DATA=/assets
23 | ENV UID=998
24 | ENV PID=100
25 | ENV GIN_MODE=release
26 | VOLUME ["/config", "/assets"]
27 | RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/*
28 | RUN mkdir -p /config; \
29 | mkdir -p /assets; \
30 | mkdir -p /api
31 |
32 | RUN chmod 777 /config; \
33 | chmod 777 /assets
34 |
35 | WORKDIR /api
36 | COPY --from=builder /api/app .
37 | COPY dist ./dist
38 |
39 | EXPOSE 3000
40 |
41 | ENTRYPOINT ["./app"]
42 |
--------------------------------------------------------------------------------
/server/models/import.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | type ImportData struct {
4 | Data []ImportFillup `json:"data" binding:"required"`
5 | VehicleId string `json:"vehicleId" binding:"required"`
6 | TimeZone string `json:"timezone" binding:"required"`
7 | }
8 |
9 | type ImportFillup struct {
10 | VehicleID string `json:"vehicleId"`
11 | FuelQuantity float32 `json:"fuelQuantity"`
12 | PerUnitPrice float32 `json:"perUnitPrice"`
13 | TotalAmount float32 `json:"totalAmount"`
14 | OdoReading int `json:"odoReading"`
15 | IsTankFull *bool `json:"isTankFull"`
16 | HasMissedFillup *bool `json:"hasMissedFillup"`
17 | Comments string `json:"comments"`
18 | FillingStation string `json:"fillingStation"`
19 | UserID string `json:"userId"`
20 | Date string `json:"date"`
21 | FuelSubType string `json:"fuelSubType"`
22 | }
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | *Before creating a bug report please make sure you are using the latest docker image / code base.*
11 |
12 | **Please complete the following information**
13 | - Installation Type: [Docker/Native]
14 | - Have you tried using the latest docker image / code base [yes/no]
15 |
16 | **Describe the bug**
17 | A clear and concise description of what the bug is.
18 |
19 | **To Reproduce**
20 | Steps to reproduce the behavior:
21 | 1. Go to '...'
22 | 2. Click on '....'
23 | 3. Scroll down to '....'
24 | 4. See error
25 |
26 | **Expected behavior**
27 | A clear and concise description of what you expected to happen.
28 |
29 | **Screenshots**
30 | If applicable, add screenshots to help explain your problem.
31 |
32 |
33 | **Additional context**
34 | Add any other context about the problem here.
35 |
--------------------------------------------------------------------------------
/ui/src/app.vue:
--------------------------------------------------------------------------------
1 |
26 |
27 |
28 |
29 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/ui/tests/e2e/specs/profile.e2e.js:
--------------------------------------------------------------------------------
1 | describe('Profile Page', () => {
2 | it('redirects to login when logged out', () => {
3 | cy.visit('/profile')
4 | cy.location('pathname').should('equal', '/login')
5 | })
6 |
7 | it('nav link exists when logged in', () => {
8 | cy.logIn()
9 | cy.contains('a', 'Logged in as Vue Master').should(
10 | 'have.attr',
11 | 'href',
12 | '/profile'
13 | )
14 | })
15 |
16 | it('shows the current user profile when logged in', () => {
17 | cy.logIn()
18 | cy.visit('/profile')
19 | cy.contains('h1', 'Vue Master')
20 | })
21 |
22 | it('shows non-current users at username routes when logged in', () => {
23 | cy.logIn()
24 | cy.visit('/profile/user1')
25 | cy.contains('h1', 'User One')
26 | })
27 |
28 | it('shows a user 404 page when looking for a user that does not exist', () => {
29 | cy.logIn()
30 | cy.visit('/profile/non-existant-user')
31 | cy.contains('h1', /User\s+Not\s+Found/)
32 | })
33 | })
34 |
--------------------------------------------------------------------------------
/ui/src/router/views/_timeout.vue:
--------------------------------------------------------------------------------
1 |
31 |
32 |
33 |
34 |
35 | {{ $t('timeout') }}
36 |
37 |
38 |
39 |
40 |
41 |
46 |
--------------------------------------------------------------------------------
/ui/tests/mock-api/routes/auth.js:
--------------------------------------------------------------------------------
1 | const Users = require('../resources/users')
2 |
3 | module.exports = (app) => {
4 | // Log in a user with a username and password
5 | app.post('/api/session', (request, response) => {
6 | Users.authenticate(request.body)
7 | .then((user) => {
8 | response.json(user)
9 | })
10 | .catch((error) => {
11 | response.status(401).json({ message: error.message })
12 | })
13 | })
14 |
15 | // Get the user of a provided token, if valid
16 | app.get('/api/session', (request, response) => {
17 | const currentUser = Users.findBy('token', request.headers.authorization)
18 |
19 | if (!currentUser) {
20 | return response.status(401).json({
21 | message:
22 | 'The token is either invalid or has expired. Please log in again.',
23 | })
24 | }
25 |
26 | response.json(currentUser)
27 | })
28 |
29 | // A simple ping for checking online status
30 | app.get('/api/ping', (request, response) => {
31 | response.send('OK')
32 | })
33 | }
34 |
--------------------------------------------------------------------------------
/ui/docs/editors.md:
--------------------------------------------------------------------------------
1 | # Editor integration
2 |
3 | - [Visual Studio Code](#visual-studio-code)
4 | - [Configuration](#configuration)
5 | - [FAQ](#faq)
6 |
7 | ## Visual Studio Code
8 |
9 | This project is best developed in VS Code. With the [recommended extensions](https://code.visualstudio.com/docs/editor/extension-gallery#_workspace-recommended-extensions) and settings in `.vscode`, you get:
10 |
11 | - Syntax highlighting for all files
12 | - Intellisense for all files
13 | - Lint-on-save for all files
14 | - In-editor results on save for unit tests
15 |
16 | ### Configuration
17 |
18 | To configure
19 |
20 | - `.vscode/extensions.json`
21 | - `.vscode/settings.json`
22 |
23 | ## FAQ
24 |
25 | **What kinds of editor settings and extensions should be added to the project?**
26 |
27 | All additions must:
28 |
29 | - be specific to this project
30 | - not interfere with any team member's workflow
31 |
32 | For example, an extension to add syntax highlighting for an included language will almost certainly be welcome, but a setting to change the editor's color theme wouldn't be appropriate.
33 |
--------------------------------------------------------------------------------
/server/controllers/reports.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "net/http"
5 |
6 | "hammond/common"
7 | "hammond/models"
8 | "hammond/service"
9 |
10 | "github.com/gin-gonic/gin"
11 | )
12 |
13 | func RegisterReportsController(router *gin.RouterGroup) {
14 | router.GET("/vehicles/:id/mileage", getMileageForVehicle)
15 | }
16 |
17 | func getMileageForVehicle(c *gin.Context) {
18 |
19 | var searchByIdQuery models.SearchByIdQuery
20 |
21 | if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
22 | var model models.MileageQueryModel
23 | err := c.BindQuery(&model)
24 | if err != nil {
25 | c.JSON(http.StatusUnprocessableEntity, common.NewError("getMileageForVehicle", err))
26 | return
27 | }
28 |
29 | fillups, err := service.GetMileageByVehicleId(searchByIdQuery.Id, model.Since, model.MileageOption)
30 | if err != nil {
31 | c.JSON(http.StatusUnprocessableEntity, common.NewError("getMileageForVehicle", err))
32 | return
33 | }
34 | c.JSON(http.StatusOK, fillups)
35 | } else {
36 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/ui/tests/mock-api/resources/users.js:
--------------------------------------------------------------------------------
1 | const _ = require('lodash')
2 |
3 | module.exports = {
4 | all: [
5 | {
6 | id: 1,
7 | username: 'admin',
8 | password: 'password',
9 | name: 'Vue Master',
10 | },
11 | {
12 | id: 2,
13 | username: 'user1',
14 | password: 'password',
15 | name: 'User One',
16 | },
17 | ].map((user) => {
18 | return {
19 | ...user,
20 | token: `valid-token-for-${user.username}`,
21 | }
22 | }),
23 | authenticate({ username, password }) {
24 | return new Promise((resolve, reject) => {
25 | const matchedUser = this.all.find(
26 | (user) => user.username === username && user.password === password
27 | )
28 | if (matchedUser) {
29 | resolve(this.json(matchedUser))
30 | } else {
31 | reject(new Error('Invalid user credentials.'))
32 | }
33 | })
34 | },
35 | findBy(propertyName, value) {
36 | const matchedUser = this.all.find((user) => user[propertyName] === value)
37 | return this.json(matchedUser)
38 | },
39 | json(user) {
40 | return user && _.omit(user, ['password'])
41 | },
42 | }
43 |
--------------------------------------------------------------------------------
/server/db/migrations.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "time"
7 |
8 | "gorm.io/gorm"
9 | )
10 |
11 | type localMigration struct {
12 | Name string
13 | Query string
14 | }
15 |
16 | var migrations = []localMigration{
17 | {
18 | Name: "2021_06_24_04_42_SetUserDisabledFalse",
19 | Query: "update users set is_disabled=0",
20 | },
21 | {
22 | Name: "2021_02_07_00_09_LowerCaseEmails",
23 | Query: "update users set email=lower(email)",
24 |
25 | },
26 | {
27 | Name: "2022_03_08_13_16_AddVIN",
28 | Query: "ALTER TABLE vehicles ADD COLUMN vin text",
29 | },
30 | }
31 |
32 | func RunMigrations() {
33 | for _, mig := range migrations {
34 | ExecuteAndSaveMigration(mig.Name, mig.Query)
35 | }
36 | }
37 | func ExecuteAndSaveMigration(name string, query string) error {
38 | var migration Migration
39 | result := DB.Where("name=?", name).First(&migration)
40 | if errors.Is(result.Error, gorm.ErrRecordNotFound) {
41 | fmt.Println(query)
42 | result = DB.Debug().Exec(query)
43 | if result.Error == nil {
44 | DB.Save(&Migration{
45 | Date: time.Now(),
46 | Name: name,
47 | })
48 | }
49 | return result.Error
50 | }
51 | return nil
52 | }
53 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG GO_VERSION=1.20.6
2 | FROM golang:${GO_VERSION}-alpine AS builder
3 | RUN apk update && apk add alpine-sdk git && rm -rf /var/cache/apk/*
4 | RUN mkdir -p /api
5 | WORKDIR /api
6 | COPY ./server/go.mod .
7 | COPY ./server/go.sum .
8 | RUN go mod download
9 | COPY ./server .
10 | RUN go build -o ./app ./main.go
11 |
12 | FROM node:16-alpine as build-stage
13 | WORKDIR /app
14 | COPY ./ui/package*.json ./
15 | RUN apk add --no-cache autoconf automake build-base nasm libc6-compat python3 py3-pip make g++ libpng-dev zlib-dev pngquant
16 |
17 | RUN npm install
18 | COPY ./ui .
19 | RUN npm run build
20 |
21 |
22 | FROM alpine:latest
23 | LABEL org.opencontainers.image.source="https://github.com/alfhou/hammond"
24 | ENV CONFIG=/config
25 | ENV DATA=/assets
26 | ENV UID=998
27 | ENV PID=100
28 | ENV GIN_MODE=release
29 | VOLUME ["/config", "/assets"]
30 | RUN apk update && apk add ca-certificates tzdata && rm -rf /var/cache/apk/*
31 | RUN mkdir -p /config; \
32 | mkdir -p /assets; \
33 | mkdir -p /api
34 | RUN chmod 777 /config; \
35 | chmod 777 /assets
36 | WORKDIR /api
37 | COPY --from=builder /api/app .
38 | #COPY dist ./dist
39 | COPY --from=build-stage /app/dist ./dist
40 | EXPOSE 3000
41 | ENTRYPOINT ["./app"]
42 |
--------------------------------------------------------------------------------
/ui/src/utils/format-date-relative.unit.js:
--------------------------------------------------------------------------------
1 | import formatDateRelative from './format-date-relative'
2 |
3 | describe('@utils/format-date-relative', () => {
4 | it('correctly compares dates years apart', () => {
5 | const fromDate = new Date(2002, 5, 1)
6 | const toDate = new Date(2017, 4, 10)
7 | const timeAgoInWords = formatDateRelative(fromDate, toDate)
8 | expect(timeAgoInWords).toEqual('almost 15 years')
9 | })
10 |
11 | it('correctly compares dates months apart', () => {
12 | const fromDate = new Date(2017, 8, 1)
13 | const toDate = new Date(2017, 11, 10)
14 | const timeAgoInWords = formatDateRelative(fromDate, toDate)
15 | expect(timeAgoInWords).toEqual('3 months')
16 | })
17 |
18 | it('correctly compares dates days apart', () => {
19 | const fromDate = new Date(2017, 11, 1)
20 | const toDate = new Date(2017, 11, 10)
21 | const timeAgoInWords = formatDateRelative(fromDate, toDate)
22 | expect(timeAgoInWords).toEqual('9 days')
23 | })
24 |
25 | it('compares to now when passed only one date', () => {
26 | const fromDate = new Date(2010, 11, 1)
27 | const timeAgoInWords = formatDateRelative(fromDate)
28 | expect(timeAgoInWords).toContain('years ago')
29 | })
30 | })
31 |
--------------------------------------------------------------------------------
/ui/src/components/_base-icon.vue:
--------------------------------------------------------------------------------
1 |
35 |
36 |
37 |
42 |
47 |
48 |
--------------------------------------------------------------------------------
/ui/generators/new/component/prompt.js:
--------------------------------------------------------------------------------
1 | const _ = require('lodash')
2 |
3 | module.exports = [
4 | {
5 | type: 'input',
6 | name: 'name',
7 | message: 'Name:',
8 | validate(value) {
9 | if (!value.length) {
10 | return 'Components must have a name.'
11 | }
12 | const fileName = _.kebabCase(value)
13 | if (fileName.indexOf('-') === -1) {
14 | return 'Component names should contain at least two words to avoid conflicts with existing and future HTML elements.'
15 | }
16 | return true
17 | },
18 | },
19 | {
20 | type: 'multiselect',
21 | name: 'blocks',
22 | message: 'Blocks:',
23 | initial: ['script', 'template', 'style'],
24 | choices: [
25 | {
26 | name: 'script',
27 | message: '
33 |
34 |
35 |
48 |
49 |
--------------------------------------------------------------------------------
/ui/src/utils/dispatch-action-for-all-modules.js:
--------------------------------------------------------------------------------
1 | import allModules from '@state/modules'
2 | import store from '@state/store'
3 |
4 | export default function dispatchActionForAllModules(
5 | actionName,
6 | { modules = allModules, modulePrefix = '', flags = {} } = {}
7 | ) {
8 | // For every module...
9 | for (const moduleName in modules) {
10 | const moduleDefinition = modules[moduleName]
11 |
12 | // If the action is defined on the module...
13 | if (moduleDefinition.actions && moduleDefinition.actions[actionName]) {
14 | // Dispatch the action if the module is namespaced. Otherwise,
15 | // set a flag to dispatch the action globally at the end.
16 | if (moduleDefinition.namespaced) {
17 | store.dispatch(`${modulePrefix}${moduleName}/${actionName}`)
18 | } else {
19 | flags.dispatchGlobal = true
20 | }
21 | }
22 |
23 | // If there are any nested sub-modules...
24 | if (moduleDefinition.modules) {
25 | // Also dispatch the action for these sub-modules.
26 | dispatchActionForAllModules(actionName, {
27 | modules: moduleDefinition.modules,
28 | modulePrefix: modulePrefix + moduleName + '/',
29 | flags,
30 | })
31 | }
32 | }
33 |
34 | // If this is the root and at least one non-namespaced module
35 | // was found with the action...
36 | if (!modulePrefix && flags.dispatchGlobal) {
37 | // Dispatch the action globally.
38 | store.dispatch(actionName)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/server/db/db.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "os"
7 | "path"
8 |
9 | "gorm.io/driver/sqlite"
10 |
11 | "gorm.io/gorm"
12 | )
13 |
14 | //DB is
15 | var DB *gorm.DB
16 |
17 | //Init is used to Initialize Database
18 | func Init() (*gorm.DB, error) {
19 | // github.com/mattn/go-sqlite3
20 | configPath := os.Getenv("CONFIG")
21 | dbPath := path.Join(configPath, "hammond.db")
22 | log.Println(dbPath)
23 | db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
24 | DisableForeignKeyConstraintWhenMigrating: true,
25 | })
26 | if err != nil {
27 | fmt.Println("db err: ", err)
28 | return nil, err
29 | }
30 |
31 | localDB, _ := db.DB()
32 | localDB.SetMaxIdleConns(10)
33 | //db.LogMode(true)
34 | DB = db
35 | return DB, nil
36 | }
37 |
38 | //Migrate Database
39 | func Migrate() {
40 | err := DB.AutoMigrate(&Attachment{}, &QuickEntry{}, &User{}, &Vehicle{}, &UserVehicle{}, &VehicleAttachment{}, &Fillup{}, &Expense{}, &Setting{}, &JobLock{}, &Migration{})
41 | if err != nil {
42 | fmt.Println("1 " + err.Error())
43 | }
44 | err = DB.SetupJoinTable(&User{}, "Vehicles", &UserVehicle{})
45 | if err != nil {
46 | fmt.Println(err.Error())
47 | }
48 | err = DB.SetupJoinTable(&Vehicle{}, "Attachments", &VehicleAttachment{})
49 | if err != nil {
50 | fmt.Println(err.Error())
51 | }
52 | RunMigrations()
53 | }
54 |
55 | // Using this function to get a connection, you can create your connection pool here.
56 | func GetDB() *gorm.DB {
57 | return DB
58 | }
59 |
--------------------------------------------------------------------------------
/ui/src/state/modules/utils.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 |
3 | export const state = {
4 | isMobile: false,
5 | settings: null,
6 | }
7 | export const mutations = {
8 | CACHE_ISMOBILE(state, isMobile) {
9 | state.isMobile = isMobile
10 | },
11 | CACHE_SETTINGS(state, settings) {
12 | state.settings = settings
13 | },
14 | }
15 | export const getters = {}
16 | export const actions = {
17 | init({ dispatch, rootState }) {
18 | dispatch('checkSize')
19 | const { currentUser } = rootState.auth
20 | if (currentUser) {
21 | dispatch('getSettings')
22 | }
23 | },
24 | checkSize({ commit }) {
25 | commit('CACHE_ISMOBILE', window.innerWidth < 600)
26 | return window.innerWidth < 600
27 | },
28 | getSettings({ commit }) {
29 | return axios.get(`/api/settings`).then((response) => {
30 | const data = response.data
31 | commit('CACHE_SETTINGS', data)
32 | return data
33 | })
34 | },
35 | saveSettings({ commit, dispatch }, { settings }) {
36 | return axios.post(`/api/settings`, { ...settings }).then((response) => {
37 | const data = response.data
38 | dispatch('getSettings')
39 | return data
40 | })
41 | },
42 | saveUserSettings({ commit, dispatch }, { settings }) {
43 | return axios.post(`/api/me/settings`, { ...settings }).then((response) => {
44 | const data = response.data
45 | dispatch('users/forceMe', {}, { root: true }).then((data) => {})
46 | return data
47 | })
48 | },
49 | }
50 |
--------------------------------------------------------------------------------
/server/service/genericImportService.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "hammond/db"
5 | "hammond/models"
6 | "time"
7 | )
8 |
9 | func GenericParseRefuelings(content []models.ImportFillup, user *db.User, vehicle *db.Vehicle, timezone string) ([]db.Fillup, []string) {
10 | var errors []string
11 | var fillups []db.Fillup
12 | dateLayout := "2006-01-02T15:04:05.000Z"
13 | loc, _ := time.LoadLocation(timezone)
14 | for _, record := range content {
15 | date, err := time.ParseInLocation(dateLayout, record.Date, loc)
16 | if err != nil {
17 | date = time.Date(2000, time.December, 0, 0, 0, 0, 0, loc)
18 | }
19 |
20 | var missedFillup bool
21 | if record.HasMissedFillup == nil {
22 | missedFillup = false
23 | } else {
24 | missedFillup = *record.HasMissedFillup
25 | }
26 |
27 | fillups = append(fillups, db.Fillup{
28 | VehicleID: vehicle.ID,
29 | UserID: user.ID,
30 | Date: date,
31 | IsTankFull: record.IsTankFull,
32 | HasMissedFillup: &missedFillup,
33 | FuelQuantity: float32(record.FuelQuantity),
34 | PerUnitPrice: float32(record.PerUnitPrice),
35 | FillingStation: record.FillingStation,
36 | OdoReading: record.OdoReading,
37 | TotalAmount: float32(record.TotalAmount),
38 | FuelUnit: vehicle.FuelUnit,
39 | Currency: user.Currency,
40 | DistanceUnit: user.DistanceUnit,
41 | Comments: record.Comments,
42 | Source: "Generic Import",
43 | })
44 | }
45 |
46 | return fillups, errors
47 | }
48 |
--------------------------------------------------------------------------------
/server/models/report.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "encoding/json"
5 | "time"
6 |
7 | "hammond/db"
8 | )
9 |
10 | type MileageModel struct {
11 | Date time.Time `form:"date" json:"date" binding:"required" time_format:"2006-01-02"`
12 | VehicleID string `form:"vehicleId" json:"vehicleId" binding:"required"`
13 | FuelUnit db.FuelUnit `form:"fuelUnit" json:"fuelUnit" binding:"required"`
14 | FuelQuantity float32 `form:"fuelQuantity" json:"fuelQuantity" binding:"required"`
15 | PerUnitPrice float32 `form:"perUnitPrice" json:"perUnitPrice" binding:"required"`
16 | Currency string `json:"currency"`
17 | DistanceUnit db.DistanceUnit `form:"distanceUnit" json:"distanceUnit"`
18 | Mileage float32 `form:"mileage" json:"mileage" binding:"mileage"`
19 | CostPerMile float32 `form:"costPerMile" json:"costPerMile" binding:"costPerMile"`
20 | OdoReading int `form:"odoReading" json:"odoReading" binding:"odoReading"`
21 | }
22 |
23 | func (v *MileageModel) FuelUnitDetail() db.EnumDetail {
24 | return db.FuelUnitDetails[v.FuelUnit]
25 | }
26 | func (b *MileageModel) MarshalJSON() ([]byte, error) {
27 | return json.Marshal(struct {
28 | MileageModel
29 | FuelUnitDetail db.EnumDetail `json:"fuelUnitDetail"`
30 | }{
31 | MileageModel: *b,
32 | FuelUnitDetail: b.FuelUnitDetail(),
33 | })
34 | }
35 |
36 | type MileageQueryModel struct {
37 | Since time.Time `json:"since" query:"since" form:"since"`
38 | MileageOption string `json:"mileageOption" query:"mileageOption" form:"mileageOption"`
39 | }
40 |
--------------------------------------------------------------------------------
/server/controllers/users.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "net/http"
5 |
6 | "hammond/common"
7 | "hammond/db"
8 | "hammond/models"
9 | "hammond/service"
10 |
11 | "github.com/gin-gonic/gin"
12 | )
13 |
14 | func RegisterUserController(router *gin.RouterGroup) {
15 | router.GET("/users", allUsers)
16 | router.POST("/users/:id/enable", ShouldBeAdmin(), enableUser)
17 | router.POST("/users/:id/disable", ShouldBeAdmin(), disableUser)
18 | }
19 |
20 | func allUsers(c *gin.Context) {
21 | users, err := db.GetAllUsers()
22 | if err != nil {
23 | c.JSON(http.StatusBadRequest, err)
24 | return
25 | }
26 | c.JSON(http.StatusOK, users)
27 |
28 | }
29 | func enableUser(c *gin.Context) {
30 | var searchByIdQuery models.SearchByIdQuery
31 | if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
32 | err := service.SetDisabledStatusForUser(searchByIdQuery.Id, false)
33 | if err != nil {
34 | c.JSON(http.StatusBadRequest, err)
35 | return
36 | }
37 | c.JSON(http.StatusOK, gin.H{})
38 | } else {
39 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
40 | }
41 |
42 | }
43 | func disableUser(c *gin.Context) {
44 | var searchByIdQuery models.SearchByIdQuery
45 | if err := c.ShouldBindUri(&searchByIdQuery); err == nil {
46 | err := service.SetDisabledStatusForUser(searchByIdQuery.Id, true)
47 | if err != nil {
48 | c.JSON(http.StatusBadRequest, err)
49 | return
50 | }
51 | c.JSON(http.StatusOK, gin.H{})
52 | } else {
53 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
54 | }
55 |
56 | }
57 |
--------------------------------------------------------------------------------
/server/internal/sanitize/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2017 Mechanism Design. All rights reserved.
2 |
3 | Redistribution and use in source and binary forms, with or without
4 | modification, are permitted provided that the following conditions are
5 | met:
6 |
7 | * Redistributions of source code must retain the above copyright
8 | notice, this list of conditions and the following disclaimer.
9 | * Redistributions in binary form must reproduce the above
10 | copyright notice, this list of conditions and the following disclaimer
11 | in the documentation and/or other materials provided with the
12 | distribution.
13 | * Neither the name of Google Inc. nor the names of its
14 | contributors may be used to endorse or promote products derived from
15 | this software without specific prior written permission.
16 |
17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
/ui/src/components/_base-input-text.unit.js:
--------------------------------------------------------------------------------
1 | import BaseInputText from './_base-input-text.vue'
2 |
3 | describe('@components/_base-input-text', () => {
4 | it('works with v-model', () => {
5 | const wrapper = mount(BaseInputText, { propsData: { value: 'aaa' } })
6 | const inputWrapper = wrapper.find('input')
7 | const inputEl = inputWrapper.element
8 |
9 | // Has the correct starting value
10 | expect(inputEl.value).toEqual('aaa')
11 |
12 | // Emits an update event with the correct value when edited
13 | inputEl.value = 'bbb'
14 | inputWrapper.trigger('input')
15 | expect(wrapper.emitted().update).toEqual([['bbb']])
16 |
17 | // Sets the input to the correct value when props change
18 | wrapper.setValue('ccc')
19 | expect(inputEl.value).toEqual('ccc')
20 | })
21 |
22 | it('allows a type of "password"', () => {
23 | const consoleError = jest
24 | .spyOn(console, 'error')
25 | .mockImplementation(() => {})
26 | mount(BaseInputText, {
27 | propsData: { value: 'aaa', type: 'password' },
28 | })
29 | expect(consoleError).not.toBeCalled()
30 | consoleError.mockRestore()
31 | })
32 |
33 | it('does NOT allow a type of "checkbox"', () => {
34 | const consoleError = jest
35 | .spyOn(console, 'error')
36 | .mockImplementation(() => {})
37 | mount(BaseInputText, {
38 | propsData: { value: 'aaa', type: 'checkbox' },
39 | })
40 | expect(consoleError.mock.calls[0][0]).toContain(
41 | 'custom validator check failed for prop "type"'
42 | )
43 | consoleError.mockRestore()
44 | })
45 | })
46 |
--------------------------------------------------------------------------------
/ui/vue.config.js:
--------------------------------------------------------------------------------
1 | const appConfig = require('./src/app.config')
2 |
3 | /** @type import('@vue/cli-service').ProjectOptions */
4 | module.exports = {
5 | // https://github.com/neutrinojs/webpack-chain/tree/v4#getting-started
6 | chainWebpack(config) {
7 | // We provide the app's title in Webpack's name field, so that
8 | // it can be accessed in index.html to inject the correct title.
9 | config.set('name', appConfig.title)
10 |
11 | // Set up all the aliases we use in our app.
12 | config.resolve.alias.clear().merge(require('./aliases.config').webpack)
13 |
14 | // Don't allow importing .vue files without the extension, as
15 | // it's necessary for some Vetur autocompletions.
16 | config.resolve.extensions.delete('.vue')
17 |
18 | // Only enable performance hints for production builds,
19 | // outside of tests.
20 | config.performance.hints(process.env.NODE_ENV === 'production' && !process.env.VUE_APP_TEST && 'warning')
21 | },
22 | css: {
23 | // Enable CSS source maps.
24 | sourceMap: true,
25 | },
26 | // Configure Webpack's dev server.
27 | // https://cli.vuejs.org/guide/cli-service.html
28 | devServer: {
29 | ...(process.env.API_BASE_URL
30 | ? // Proxy API endpoints to the production base URL.
31 | {
32 | proxy: {
33 | '/api': {
34 | target: process.env.API_BASE_URL,
35 | // pathRewrite: { '^/api': '/' },
36 | },
37 | },
38 | }
39 | : // Proxy API endpoints a local mock API.
40 | { before: require('./tests/mock-api') }),
41 | },
42 | }
43 |
--------------------------------------------------------------------------------
/.github/workflows/hub.yml:
--------------------------------------------------------------------------------
1 | name: Build docker image
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | multi:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout
12 | uses: actions/checkout@v2
13 | - name: Set up QEMU
14 | id: qemu
15 | uses: docker/setup-qemu-action@v2
16 | with:
17 | platforms: linux/amd64,linux/arm64,linux/arm/v7
18 | - name: Available platforms
19 | run: echo ${{ steps.qemu.outputs.platforms }}
20 | - name: Set up Docker Buildx
21 | uses: docker/setup-buildx-action@v2
22 | - name: Parse the git tag
23 | id: get_tag
24 | run: echo ::set-output name=TAG::$(echo $GITHUB_REF | cut -d / -f 3)
25 | - name: Login to DockerHub
26 | uses: docker/login-action@v2
27 | with:
28 | username: ${{ secrets.DOCKER_USERNAME }}
29 | password: ${{ secrets.DOCKER_TOKEN }}
30 | - name: Login to GitHub
31 | uses: docker/login-action@v1
32 | with:
33 | registry: ghcr.io
34 | username: ${{ github.actor }}
35 | password: ${{ secrets.GITHUB_TOKEN }}
36 | - name: Build and push
37 | uses: docker/build-push-action@v4
38 | with:
39 | context: .
40 | file: ./Dockerfile
41 | platforms: linux/amd64,linux/arm64,linux/arm/v7
42 | push: true
43 | # cache-from: type=local,src=/tmp/.buildx-cache
44 | # cache-to: type=local,dest=/tmp/.buildx-cache
45 | tags: |
46 | alfhou/hammond:latest
47 | alfhou/hammond:${{ steps.get_tag.outputs.TAG }}
48 | ghcr.io/alfhou/hammond:latest
49 | ghcr.io/alfhou/hammond:${{ steps.get_tag.outputs.TAG }}
50 |
--------------------------------------------------------------------------------
/ui/src/components/nav-bar-routes.vue:
--------------------------------------------------------------------------------
1 |
63 |
--------------------------------------------------------------------------------
/ui/src/state/modules/users.unit.js:
--------------------------------------------------------------------------------
1 | import * as usersModule from './users'
2 |
3 | describe('@state/modules/users', () => {
4 | it('exports a valid Vuex module', () => {
5 | expect(usersModule).toBeAVuexModule()
6 | })
7 |
8 | describe('in a store when logged in', () => {
9 | let store
10 | beforeEach(() => {
11 | store = createModuleStore(usersModule, {
12 | currentUser: validUserExample,
13 | })
14 | })
15 |
16 | it('actions.fetchUser returns the current user without fetching it again', () => {
17 | expect.assertions(2)
18 |
19 | const axios = require('axios')
20 | const originalAxiosGet = axios.get
21 | axios.get = jest.fn()
22 |
23 | return store.dispatch('fetchUser', { username: 'admin' }).then((user) => {
24 | expect(user).toEqual(validUserExample)
25 | expect(axios.get).not.toHaveBeenCalled()
26 | axios.get = originalAxiosGet
27 | })
28 | })
29 |
30 | it('actions.fetchUser rejects with 400 when provided a bad username', () => {
31 | expect.assertions(1)
32 |
33 | return store
34 | .dispatch('fetchUser', { username: 'bad-username' })
35 | .catch((error) => {
36 | expect(error.response.status).toEqual(400)
37 | })
38 | })
39 | })
40 |
41 | describe('in a store when logged out', () => {
42 | let store
43 | beforeEach(() => {
44 | store = createModuleStore(usersModule)
45 | })
46 |
47 | it('actions.fetchUser rejects with 401', () => {
48 | expect.assertions(1)
49 |
50 | return store
51 | .dispatch('fetchUser', { username: 'admin' })
52 | .catch((error) => {
53 | expect(error.response.status).toEqual(401)
54 | })
55 | })
56 | })
57 | })
58 |
59 | const validUserExample = {
60 | id: 1,
61 | username: 'admin',
62 | name: 'Vue Master',
63 | token: 'valid-token-for-admin',
64 | }
65 |
--------------------------------------------------------------------------------
/server/controllers/setup.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 |
7 | "hammond/common"
8 | "hammond/db"
9 | "hammond/models"
10 | "hammond/service"
11 |
12 | "github.com/gin-gonic/gin"
13 | )
14 |
15 | func RegisterSetupController(router *gin.RouterGroup) {
16 | router.POST("/clarkson/check", canMigrate)
17 | router.POST("/clarkson/migrate", migrate)
18 | router.GET("/system/status", appInitialized)
19 | }
20 |
21 | func appInitialized(c *gin.Context) {
22 | canInitialize, err := service.CanInitializeSystem()
23 | message := ""
24 | if err != nil {
25 | message = err.Error()
26 | }
27 | c.JSON(http.StatusOK, gin.H{"initialized": !canInitialize, "message": message})
28 | }
29 |
30 | func canMigrate(c *gin.Context) {
31 | var request models.ClarksonMigrationModel
32 | if err := c.ShouldBind(&request); err == nil {
33 | canMigrate, data, errr := db.CanMigrate(request.Url)
34 | errorMessage := ""
35 | if errr != nil {
36 | errorMessage = errr.Error()
37 | }
38 |
39 | c.JSON(http.StatusOK, gin.H{
40 | "canMigrate": canMigrate,
41 | "data": data,
42 | "message": errorMessage,
43 | })
44 | } else {
45 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
46 | }
47 | }
48 |
49 | func migrate(c *gin.Context) {
50 | var request models.ClarksonMigrationModel
51 | if err := c.ShouldBind(&request); err == nil {
52 | canMigrate, _, _ := db.CanMigrate(request.Url)
53 |
54 | if !canMigrate {
55 | c.JSON(http.StatusBadRequest, fmt.Errorf("cannot migrate database. please check connection string"))
56 | return
57 | }
58 |
59 | success, err := db.MigrateClarkson(request.Url)
60 | if !success {
61 | c.JSON(http.StatusBadRequest, err)
62 | return
63 | }
64 |
65 | c.JSON(http.StatusOK, gin.H{
66 | "success": success,
67 | })
68 | } else {
69 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/ui/src/router/views/import.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
22 |
23 |
{{ $t('importdata') }}
24 |
{{ $t('importdatadesc') }}
25 |
26 |
27 |
28 |
29 |
30 |
31 |
Fuelly
32 |
If you have been using Fuelly to store your vehicle data, export the CSV file from Fuelly and click here to
33 | import.
34 |
35 |
{{ $t('import') }}
36 |
37 |
38 |
39 |
40 |
41 |
Drivvo
42 |
{{ $t('importcsv', { 'name': 'Fuelly' }) }}
43 |
44 |
{{ $t('import') }}
45 |
46 |
47 |
48 |
49 |
50 |
51 |
{{ $t('importgeneric') }}
52 |
{{ $t('importgenericdesc') }}
53 |
54 |
{{ $t('import') }}
55 |
56 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/ui/tests/e2e/plugins/index.js:
--------------------------------------------------------------------------------
1 | // https://docs.cypress.io/guides/guides/plugins-guide.html
2 | module.exports = (on, config) => {
3 | // Dynamic configuration
4 | // https://docs.cypress.io/guides/references/configuration.html
5 | return Object.assign({}, config, {
6 | // ===
7 | // General
8 | // https://docs.cypress.io/guides/references/configuration.html#Global
9 | // ===
10 | watchForFileChanges: true,
11 | // ===
12 | // Environment variables
13 | // https://docs.cypress.io/guides/guides/environment-variables.html#Option-1-cypress-json
14 | // ===
15 | env: {
16 | CI: process.env.CI,
17 | },
18 | // ===
19 | // Viewport
20 | // https://docs.cypress.io/guides/references/configuration.html#Viewport
21 | // ===
22 | viewportWidth: 1280,
23 | viewportHeight: 720,
24 | // ===
25 | // Animations
26 | // https://docs.cypress.io/guides/references/configuration.html#Animations
27 | // ===
28 | waitForAnimations: true,
29 | animationDistanceThreshold: 4,
30 | // ===
31 | // Timeouts
32 | // https://docs.cypress.io/guides/references/configuration.html#Timeouts
33 | // ===
34 | defaultCommandTimeout: 4000,
35 | execTimeout: 60000,
36 | pageLoadTimeout: 60000,
37 | requestTimeout: 5000,
38 | responseTimeout: 30000,
39 | // ===
40 | // Main Directories
41 | // https://docs.cypress.io/guides/references/configuration.html#Folders-Files
42 | // ===
43 | supportFile: 'tests/e2e/support/setup.js',
44 | integrationFolder: 'tests/e2e/specs',
45 | fixturesFolder: 'tests/e2e/fixtures',
46 | // ===
47 | // Videos & Screenshots
48 | // https://docs.cypress.io/guides/core-concepts/screenshots-and-videos.html
49 | // ===
50 | videoUploadOnPasses: true,
51 | videoCompression: 32,
52 | videosFolder: 'tests/e2e/videos',
53 | screenshotsFolder: 'tests/e2e/screenshots',
54 | })
55 | }
56 |
--------------------------------------------------------------------------------
/server/db/enums.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | type FuelUnit int
4 |
5 | const (
6 | LITRE FuelUnit = iota
7 | GALLON
8 | US_GALLON
9 | KILOGRAM
10 | KILOWATT_HOUR
11 | MINUTE
12 | )
13 |
14 | type FuelType int
15 |
16 | const (
17 | PETROL FuelType = iota
18 | DIESEL
19 | ETHANOL
20 | CNG
21 | ELECTRIC
22 | LPG
23 | )
24 |
25 | type DistanceUnit int
26 |
27 | const (
28 | MILES DistanceUnit = iota
29 | KILOMETERS
30 | )
31 |
32 | type Role int
33 |
34 | const (
35 | ADMIN Role = iota
36 | USER
37 | )
38 |
39 | type AlertFrequency int
40 |
41 | const (
42 | ONETIME AlertFrequency = iota
43 | RECURRING
44 | )
45 |
46 | type AlertType int
47 |
48 | const (
49 | DISTANCE AlertType = iota
50 | TIME
51 | BOTH
52 | )
53 |
54 | type EnumDetail struct {
55 | Key string `json:"key"`
56 | }
57 |
58 | var FuelUnitDetails map[FuelUnit]EnumDetail = map[FuelUnit]EnumDetail{
59 | LITRE: {
60 | Key: "litre",
61 | },
62 | GALLON: {
63 | Key: "gallon",
64 | }, KILOGRAM: {
65 | Key: "kilogram",
66 | }, KILOWATT_HOUR: {
67 | Key: "kilowatthour",
68 | }, US_GALLON: {
69 | Key: "usgallon",
70 | },
71 | MINUTE: {
72 | Key: "minutes",
73 | },
74 | }
75 |
76 | var FuelTypeDetails map[FuelType]EnumDetail = map[FuelType]EnumDetail{
77 | PETROL: {
78 | Key: "petrol",
79 | },
80 | DIESEL: {
81 | Key: "diesel",
82 | }, CNG: {
83 | Key: "cng",
84 | }, LPG: {
85 | Key: "lpg",
86 | }, ELECTRIC: {
87 | Key: "electric",
88 | }, ETHANOL: {
89 | Key: "ethanol",
90 | },
91 | }
92 |
93 | var DistanceUnitDetails map[DistanceUnit]EnumDetail = map[DistanceUnit]EnumDetail{
94 | KILOMETERS: {
95 | Key: "kilometers",
96 | },
97 | MILES: {
98 | Key: "miles",
99 | },
100 | }
101 |
102 | var RoleDetails map[Role]EnumDetail = map[Role]EnumDetail{
103 | ADMIN: {
104 | Key: "ADMIN",
105 | },
106 | USER: {
107 | Key: "USER",
108 | },
109 | }
110 |
--------------------------------------------------------------------------------
/ui/src/components/quickEntryDisplay.vue:
--------------------------------------------------------------------------------
1 |
46 |
47 |
48 |
49 |
50 |
57 |
60 |
61 |
62 | Show
66 |
67 |
69 |
--------------------------------------------------------------------------------
/server/controllers/masters.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "net/http"
5 |
6 | "hammond/common"
7 | "hammond/db"
8 | "hammond/models"
9 | "hammond/service"
10 |
11 | "github.com/gin-gonic/gin"
12 | )
13 |
14 | func RegisterAnonMasterConroller(router *gin.RouterGroup) {
15 | router.GET("/masters", func(c *gin.Context) {
16 | c.JSON(http.StatusOK, gin.H{
17 | "fuelUnits": db.FuelUnitDetails,
18 | "fuelTypes": db.FuelTypeDetails,
19 | "distanceUnits": db.DistanceUnitDetails,
20 | "roles": db.RoleDetails,
21 | "currencies": models.GetCurrencyMasterList(),
22 | })
23 | })
24 | }
25 | func RegisterMastersController(router *gin.RouterGroup) {
26 |
27 | router.GET("/settings", getSettings)
28 | router.POST("/settings", udpateSettings)
29 | router.POST("/me/settings", udpateMySettings)
30 |
31 | }
32 |
33 | func getSettings(c *gin.Context) {
34 |
35 | c.JSON(http.StatusOK, service.GetSettings())
36 | }
37 | func udpateSettings(c *gin.Context) {
38 | var model models.UpdateSettingModel
39 | if err := c.ShouldBind(&model); err == nil {
40 | err := service.UpdateSettings(model.Currency, *model.DistanceUnit)
41 | if err != nil {
42 | c.JSON(http.StatusUnprocessableEntity, common.NewError("udpateSettings", err))
43 | return
44 | }
45 | c.JSON(http.StatusOK, gin.H{})
46 | } else {
47 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
48 | }
49 |
50 | }
51 |
52 | func udpateMySettings(c *gin.Context) {
53 | var model models.UpdateSettingModel
54 | if err := c.ShouldBind(&model); err == nil {
55 | err := service.UpdateUserSettings(c.MustGet("userId").(string), model.Currency, *model.DistanceUnit, model.DateFormat)
56 | if err != nil {
57 | c.JSON(http.StatusUnprocessableEntity, common.NewError("udpateMySettings", err))
58 | return
59 | }
60 | c.JSON(http.StatusOK, gin.H{})
61 | } else {
62 | c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
63 | }
64 |
65 | }
66 |
--------------------------------------------------------------------------------
/ui/src/components/mileageChart.vue:
--------------------------------------------------------------------------------
1 |
75 |
--------------------------------------------------------------------------------
/ui/aliases.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const fs = require('fs')
3 | const prettier = require('prettier')
4 |
5 | const aliases = {
6 | '@': '.',
7 | '@src': 'src',
8 | '@router': 'src/router',
9 | '@views': 'src/router/views',
10 | '@layouts': 'src/router/layouts',
11 | '@components': 'src/components',
12 | '@assets': 'src/assets',
13 | '@utils': 'src/utils',
14 | '@state': 'src/state',
15 | '@design': 'src/design/index.scss',
16 | }
17 |
18 | module.exports = {
19 | webpack: {},
20 | jest: {},
21 | jsconfig: {},
22 | }
23 |
24 | for (const alias in aliases) {
25 | const aliasTo = aliases[alias]
26 | module.exports.webpack[alias] = resolveSrc(aliasTo)
27 | const aliasHasExtension = /\.\w+$/.test(aliasTo)
28 | module.exports.jest[`^${alias}$`] = aliasHasExtension
29 | ? `/${aliasTo}`
30 | : `/${aliasTo}/index.js`
31 | module.exports.jest[`^${alias}/(.*)$`] = `/${aliasTo}/$1`
32 | module.exports.jsconfig[alias + '/*'] = [aliasTo + '/*']
33 | module.exports.jsconfig[alias] = aliasTo.includes('/index.')
34 | ? [aliasTo]
35 | : [
36 | aliasTo + '/index.js',
37 | aliasTo + '/index.json',
38 | aliasTo + '/index.vue',
39 | aliasTo + '/index.scss',
40 | aliasTo + '/index.css',
41 | ]
42 | }
43 |
44 | const jsconfigTemplate = require('./jsconfig.template') || {}
45 | const jsconfigPath = path.resolve(__dirname, 'jsconfig.json')
46 |
47 | fs.writeFile(
48 | jsconfigPath,
49 | prettier.format(
50 | JSON.stringify({
51 | ...jsconfigTemplate,
52 | compilerOptions: {
53 | ...(jsconfigTemplate.compilerOptions || {}),
54 | paths: module.exports.jsconfig,
55 | },
56 | }),
57 | {
58 | ...require('./.prettierrc'),
59 | parser: 'json',
60 | }
61 | ),
62 | (error) => {
63 | if (error) {
64 | console.error(
65 | 'Error while creating jsconfig.json from aliases.config.js.'
66 | )
67 | throw error
68 | }
69 | }
70 | )
71 |
72 | function resolveSrc(_path) {
73 | return path.resolve(__dirname, _path)
74 | }
75 |
--------------------------------------------------------------------------------
/server/controllers/import.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "net/http"
5 | "strconv"
6 |
7 | "hammond/models"
8 | "hammond/service"
9 |
10 | "github.com/gin-gonic/gin"
11 | )
12 |
13 | func RegisteImportController(router *gin.RouterGroup) {
14 | router.POST("/import/fuelly", fuellyImport)
15 | router.POST("/import/drivvo", drivvoImport)
16 | router.POST("/import/generic", genericImport)
17 | }
18 |
19 | func fuellyImport(c *gin.Context) {
20 | bytes, err := getFileBytes(c, "file")
21 | if err != nil {
22 | c.JSON(http.StatusUnprocessableEntity, err)
23 | return
24 | }
25 | errors := service.FuellyImport(bytes, c.MustGet("userId").(string))
26 | if len(errors) > 0 {
27 | c.JSON(http.StatusUnprocessableEntity, gin.H{"errors": errors})
28 | return
29 | }
30 | c.JSON(http.StatusOK, gin.H{})
31 | }
32 |
33 | func drivvoImport(c *gin.Context) {
34 | bytes, err := getFileBytes(c, "file")
35 | if err != nil {
36 | c.JSON(http.StatusUnprocessableEntity, err)
37 | return
38 | }
39 | vehicleId := c.PostForm("vehicleID")
40 | if vehicleId == "" {
41 | c.JSON(http.StatusUnprocessableEntity, "Missing Vehicle ID")
42 | return
43 | }
44 | importLocation, err := strconv.ParseBool(c.PostForm("importLocation"))
45 | if err != nil {
46 | c.JSON(http.StatusUnprocessableEntity, "Please include importLocation option.")
47 | return
48 | }
49 |
50 | errors := service.DrivvoImport(bytes, c.MustGet("userId").(string), vehicleId, importLocation)
51 | if len(errors) > 0 {
52 | c.JSON(http.StatusUnprocessableEntity, gin.H{"errors": errors})
53 | return
54 | }
55 | c.JSON(http.StatusOK, gin.H{})
56 | }
57 |
58 | func genericImport(c *gin.Context) {
59 | var json models.ImportData
60 | if err := c.ShouldBindJSON(&json); err != nil {
61 | c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
62 | return
63 | }
64 | if json.VehicleId == "" {
65 | c.JSON(http.StatusUnprocessableEntity, "Missing Vehicle ID")
66 | return
67 | }
68 | errors := service.GenericImport(json, c.MustGet("userId").(string))
69 | if len(errors) > 0 {
70 | c.JSON(http.StatusUnprocessableEntity, gin.H{"errors": errors})
71 | return
72 | }
73 | c.JSON(http.StatusOK, gin.H{})
74 | }
75 |
--------------------------------------------------------------------------------
/ui/src/main.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import Buefy from 'buefy'
3 | import router from '@router'
4 | import store from '@state/store'
5 | import { library } from '@fortawesome/fontawesome-svg-core'
6 | import {
7 | faCheck,
8 | faTimes,
9 | faArrowUp,
10 | faArrowRotateLeft,
11 | faAngleLeft,
12 | faAngleRight,
13 | faCalendar,
14 | faEdit,
15 | faAngleDown,
16 | faAngleUp,
17 | faUpload,
18 | faExclamationCircle,
19 | faDownload,
20 | faEye,
21 | faEyeSlash,
22 | faTrash,
23 | faShare,
24 | faUserFriends,
25 | faTimesCircle,
26 | } from '@fortawesome/free-solid-svg-icons'
27 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
28 | import i18n from './i18n';
29 |
30 | import App from './app.vue'
31 |
32 | // Globally register all `_base`-prefixed components
33 | import '@components/_globals'
34 |
35 | import 'buefy/dist/buefy.css'
36 | import 'nprogress/nprogress.css'
37 |
38 | library.add(
39 | faCheck,
40 | faTimes,
41 | faArrowUp,
42 | faArrowRotateLeft,
43 | faAngleLeft,
44 | faAngleRight,
45 | faCalendar,
46 | faEdit,
47 | faAngleDown,
48 | faAngleUp,
49 | faUpload,
50 | faExclamationCircle,
51 | faDownload,
52 | faEye,
53 | faEyeSlash,
54 | faTrash,
55 | faShare,
56 | faUserFriends,
57 | faTimesCircle
58 | );
59 | Vue.component('VueFontawesome', FontAwesomeIcon)
60 |
61 | Vue.use(Buefy, {
62 | defaultIconComponent: 'vue-fontawesome',
63 | defaultIconPack: 'fas',
64 | })
65 |
66 | // Don't warn about using the dev version of Vue in development.
67 | Vue.config.productionTip = process.env.NODE_ENV === 'production'
68 |
69 | // If running inside Cypress...
70 | if (process.env.VUE_APP_TEST === 'e2e') {
71 | // Ensure tests fail when Vue emits an error.
72 | Vue.config.errorHandler = window.Cypress.cy.onUncaughtException
73 | }
74 |
75 | const app = new Vue({
76 | router,
77 | store,
78 |
79 | render: (h) => h(App),
80 | i18n,
81 | }).$mount('#app')
82 |
83 | // If running e2e tests...
84 | if (process.env.VUE_APP_TEST === 'e2e') {
85 | // Attach the app to the window, which can be useful
86 | // for manually setting state in Cypress commands
87 | // such as `cy.logIn()`.
88 | window.__app__ = app
89 | }
90 |
--------------------------------------------------------------------------------
/ui/stylelint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: [
3 | // Use the Standard config as the base
4 | // https://github.com/stylelint/stylelint-config-standard
5 | 'stylelint-config-standard',
6 | // Enforce a standard order for CSS properties
7 | // https://github.com/stormwarning/stylelint-config-recess-order
8 | 'stylelint-config-recess-order',
9 | // Override rules that would interfere with Prettier
10 | // https://github.com/shannonmoeller/stylelint-config-prettier
11 | 'stylelint-config-prettier',
12 | // Override rules to allow linting of CSS modules
13 | // https://github.com/pascalduez/stylelint-config-css-modules
14 | 'stylelint-config-css-modules',
15 | ],
16 | plugins: [
17 | // Bring in some extra rules for SCSS
18 | 'stylelint-scss',
19 | ],
20 | // Rule lists:
21 | // - https://stylelint.io/user-guide/rules/
22 | // - https://github.com/kristerkari/stylelint-scss#list-of-rules
23 | rules: {
24 | // Allow newlines inside class attribute values
25 | 'string-no-newline': null,
26 | // Enforce camelCase for classes and ids, to work better
27 | // with CSS modules
28 | 'selector-class-pattern': /^[a-z][a-zA-Z]*(-(enter|leave)(-(active|to))?)?$/,
29 | 'selector-id-pattern': /^[a-z][a-zA-Z]*$/,
30 | // Limit the number of universal selectors in a selector,
31 | // to avoid very slow selectors
32 | 'selector-max-universal': 1,
33 | // Disallow allow global element/type selectors in scoped modules
34 | 'selector-max-type': [0, { ignore: ['child', 'descendant', 'compounded'] }],
35 | // ===
36 | // SCSS
37 | // ===
38 | 'scss/dollar-variable-colon-space-after': 'always',
39 | 'scss/dollar-variable-colon-space-before': 'never',
40 | 'scss/dollar-variable-no-missing-interpolation': true,
41 | 'scss/dollar-variable-pattern': /^[a-z-]+$/,
42 | 'scss/double-slash-comment-whitespace-inside': 'always',
43 | 'scss/operator-no-newline-before': true,
44 | 'scss/operator-no-unspaced': true,
45 | 'scss/selector-no-redundant-nesting-selector': true,
46 | // Allow SCSS and CSS module keywords beginning with `@`
47 | 'at-rule-no-unknown': null,
48 | 'scss/at-rule-no-unknown': true,
49 | },
50 | }
51 |
--------------------------------------------------------------------------------
/server/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "os"
7 |
8 | "hammond/controllers"
9 | "hammond/db"
10 | "hammond/service"
11 |
12 | "github.com/gin-contrib/location"
13 | "github.com/gin-gonic/contrib/static"
14 | "github.com/gin-gonic/gin"
15 | "github.com/jasonlvhit/gocron"
16 | _ "github.com/joho/godotenv/autoload"
17 | )
18 |
19 | func main() {
20 | var err error
21 | db.DB, err = db.Init()
22 | if err != nil {
23 | fmt.Println("status: ", err)
24 | } else {
25 | db.Migrate()
26 | }
27 | r := gin.Default()
28 |
29 | r.Use(setupSettings())
30 | r.Use(gin.Recovery())
31 | r.Use(location.Default())
32 | r.Use(static.Serve("/", static.LocalFile("./dist", true)))
33 | r.NoRoute(func(c *gin.Context) {
34 | //fmt.'Println(c.Request.URL.Path)
35 | c.File("dist/index.html")
36 | })
37 | router := r.Group("/api")
38 |
39 | dataPath := os.Getenv("DATA")
40 |
41 | router.Static("/assets/", dataPath)
42 |
43 | controllers.RegisterAnonController(router)
44 | controllers.RegisterAnonMasterConroller(router)
45 | controllers.RegisterSetupController(router)
46 |
47 | router.Use(controllers.AuthMiddleware(true))
48 | controllers.RegisterUserController(router)
49 | controllers.RegisterMastersController(router)
50 | controllers.RegisterAuthController(router)
51 | controllers.RegisterVehicleController(router)
52 | controllers.RegisterFilesController(router)
53 | controllers.RegisteImportController(router)
54 | controllers.RegisterReportsController(router)
55 |
56 | go assetEnv()
57 | go intiCron()
58 |
59 | r.Run(":3000") // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
60 |
61 | }
62 | func setupSettings() gin.HandlerFunc {
63 | return func(c *gin.Context) {
64 |
65 | setting := db.GetOrCreateSetting()
66 | c.Set("setting", setting)
67 | c.Writer.Header().Set("X-Clacks-Overhead", "GNU Terry Pratchett")
68 |
69 | c.Next()
70 | }
71 | }
72 |
73 | func intiCron() {
74 |
75 | //gocron.Every(uint64(checkFrequency)).Minutes().Do(service.DownloadMissingEpisodes)
76 | gocron.Every(2).Days().Do(service.CreateBackup)
77 | <-gocron.Start()
78 | }
79 |
80 | func assetEnv() {
81 | log.Println("Config Dir: ", os.Getenv("CONFIG"))
82 | log.Println("Assets Dir: ", os.Getenv("DATA"))
83 | }
84 |
--------------------------------------------------------------------------------
/server/internal/sanitize/README.md:
--------------------------------------------------------------------------------
1 | sanitize [](https://godoc.org/github.com/kennygrant/sanitize) [](https://goreportcard.com/report/github.com/kennygrant/sanitize) [](https://circleci.com/gh/kennygrant/sanitize)
2 | ========
3 |
4 | Package sanitize provides functions to sanitize html and paths with go (golang).
5 |
6 | FUNCTIONS
7 |
8 |
9 | ```go
10 | sanitize.Accents(s string) string
11 | ```
12 |
13 | Accents replaces a set of accented characters with ascii equivalents.
14 |
15 | ```go
16 | sanitize.BaseName(s string) string
17 | ```
18 |
19 | BaseName makes a string safe to use in a file name, producing a sanitized basename replacing . or / with -. Unlike Name no attempt is made to normalise text as a path.
20 |
21 | ```go
22 | sanitize.HTML(s string) string
23 | ```
24 |
25 | HTML strips html tags with a very simple parser, replace common entities, and escape < and > in the result. The result is intended to be used as plain text.
26 |
27 | ```go
28 | sanitize.HTMLAllowing(s string, args...[]string) (string, error)
29 | ```
30 |
31 | HTMLAllowing parses html and allow certain tags and attributes from the lists optionally specified by args - args[0] is a list of allowed tags, args[1] is a list of allowed attributes. If either is missing default sets are used.
32 |
33 | ```go
34 | sanitize.Name(s string) string
35 | ```
36 |
37 | Name makes a string safe to use in a file name by first finding the path basename, then replacing non-ascii characters.
38 |
39 | ```go
40 | sanitize.Path(s string) string
41 | ```
42 |
43 | Path makes a string safe to use as an url path.
44 |
45 |
46 | Changes
47 | -------
48 |
49 | Version 1.2
50 |
51 | Adjusted HTML function to avoid linter warning
52 | Added more tests from https://githubengineering.com/githubs-post-csp-journey/
53 | Chnaged name of license file
54 | Added badges and change log to readme
55 |
56 | Version 1.1
57 | Fixed type in comments.
58 | Merge pull request from Povilas Balzaravicius Pawka
59 | - replace br tags with newline even when they contain a space
60 |
61 | Version 1.0
62 | First release
--------------------------------------------------------------------------------
/server/controllers/middlewares.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "net/http"
5 | "os"
6 | "strings"
7 |
8 | "hammond/db"
9 |
10 | "github.com/dgrijalva/jwt-go"
11 | "github.com/dgrijalva/jwt-go/request"
12 | "github.com/gin-gonic/gin"
13 | )
14 |
15 | // Strips 'BEARER ' prefix from token string
16 | func stripBearerPrefixFromTokenString(tok string) (string, error) {
17 | // Should be a bearer token
18 | if len(tok) > 6 && strings.ToUpper(tok[0:6]) == "BEARER " {
19 | return tok[7:], nil
20 | }
21 | return tok, nil
22 | }
23 |
24 | // Extract token from Authorization header
25 | // Uses PostExtractionFilter to strip "TOKEN " prefix from header
26 | var AuthorizationHeaderExtractor = &request.PostExtractionFilter{
27 | Extractor: request.HeaderExtractor{"Authorization"},
28 | Filter: stripBearerPrefixFromTokenString,
29 | }
30 |
31 | // Extractor for OAuth2 access tokens. Looks in 'Authorization'
32 | // header then 'access_token' argument for a token.
33 | var MyAuth2Extractor = &request.MultiExtractor{
34 | AuthorizationHeaderExtractor,
35 | request.ArgumentExtractor{"access_token"},
36 | }
37 |
38 | // A helper to write user_id and user_model to the context
39 | func UpdateContextUserModel(c *gin.Context, my_user_id string) {
40 | var myUserModel db.User
41 | if my_user_id != "" {
42 |
43 | db.DB.First(&myUserModel, map[string]string{
44 | "ID": my_user_id,
45 | })
46 | }
47 | c.Set("userId", my_user_id)
48 | c.Set("userModel", myUserModel)
49 | }
50 |
51 | // You can custom middlewares yourself as the doc: https://github.com/gin-gonic/gin#custom-middleware
52 | // r.Use(AuthMiddleware(true))
53 | func AuthMiddleware(auto401 bool) gin.HandlerFunc {
54 | return func(c *gin.Context) {
55 | UpdateContextUserModel(c, "")
56 | token, err := request.ParseFromRequest(c.Request, MyAuth2Extractor, func(token *jwt.Token) (interface{}, error) {
57 | b := ([]byte(os.Getenv("JWT_SECRET")))
58 | return b, nil
59 | })
60 | if err != nil {
61 | if auto401 {
62 | c.AbortWithError(http.StatusUnauthorized, err)
63 | }
64 | return
65 | }
66 | if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
67 | my_user_id := claims["id"].(string)
68 | //fmt.Println(my_user_id,claims["id"])
69 | UpdateContextUserModel(c, my_user_id)
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/ui/jest.config.js:
--------------------------------------------------------------------------------
1 | const _ = require('lodash')
2 | // Use a random port number for the mock API by default,
3 | // to support multiple instances of Jest running
4 | // simultaneously, like during pre-commit lint.
5 | process.env.MOCK_API_PORT = process.env.MOCK_API_PORT || _.random(9000, 9999)
6 |
7 | module.exports = {
8 | setupFiles: ['/tests/unit/setup'],
9 | globalSetup: '/tests/unit/global-setup',
10 | globalTeardown: '/tests/unit/global-teardown',
11 | setupFilesAfterEnv: ['/tests/unit/matchers'],
12 | testMatch: ['**/(*.)unit.js'],
13 | moduleFileExtensions: ['js', 'json', 'vue'],
14 | transform: {
15 | '^.+\\.vue$': 'vue-jest',
16 | '^.+\\.js$': 'babel-jest',
17 | '.+\\.(css|scss|jpe?g|png|gif|webp|svg|mp4|webm|ogg|mp3|wav|flac|aac|woff2?|eot|ttf|otf)$':
18 | 'jest-transform-stub',
19 | },
20 | moduleNameMapper: require('./aliases.config').jest,
21 | snapshotSerializers: ['jest-serializer-vue'],
22 | coverageDirectory: '/tests/unit/coverage',
23 | collectCoverageFrom: [
24 | 'src/**/*.{js,vue}',
25 | '!**/node_modules/**',
26 | '!src/main.js',
27 | '!src/app.vue',
28 | '!src/router/index.js',
29 | '!src/router/routes.js',
30 | '!src/state/store.js',
31 | '!src/state/helpers.js',
32 | '!src/state/modules/index.js',
33 | '!src/components/_globals.js',
34 | ],
35 | // https://facebook.github.io/jest/docs/en/configuration.html#testurl-string
36 | // Set the `testURL` to a provided base URL if one exists, or the mock API base URL
37 | // Solves: https://stackoverflow.com/questions/42677387/jest-returns-network-error-when-doing-an-authenticated-request-with-axios
38 | testURL:
39 | process.env.API_BASE_URL || `http://localhost:${process.env.MOCK_API_PORT}`,
40 | // https://github.com/jest-community/jest-watch-typeahead
41 | watchPlugins: [
42 | 'jest-watch-typeahead/filename',
43 | 'jest-watch-typeahead/testname',
44 | ],
45 | globals: {
46 | 'vue-jest': {
47 | // Compilation errors in the
122 |
--------------------------------------------------------------------------------
/server/common/utils.go:
--------------------------------------------------------------------------------
1 | // Common tools and helper functions
2 | package common
3 |
4 | import (
5 | "fmt"
6 | "math/rand"
7 | "os"
8 | "time"
9 |
10 | "hammond/db"
11 |
12 | "github.com/dgrijalva/jwt-go"
13 | "github.com/gin-gonic/gin"
14 | "github.com/gin-gonic/gin/binding"
15 | "github.com/go-playground/validator/v10"
16 | )
17 |
18 | var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
19 |
20 | // A helper function to generate random string
21 | func RandString(n int) string {
22 | b := make([]rune, n)
23 | for i := range b {
24 | b[i] = letters[rand.Intn(len(letters))]
25 | }
26 | return string(b)
27 | }
28 |
29 | // A helper to convert from litres to gallon
30 | func LitreToGallon(litres float32) float32 {
31 | gallonConversionFactor := 0.21997
32 | return litres * float32(gallonConversionFactor);
33 | }
34 |
35 | // A helper to convert from gallon to litres
36 | func GallonToLitre(gallons float32) float32 {
37 | litreConversionFactor := 3.785412
38 | return gallons * float32(litreConversionFactor);
39 | }
40 |
41 |
42 | // A helper to convert from km to miles
43 | func KmToMiles(km float32) float32 {
44 | kmConversionFactor := 0.62137119
45 | return km * float32(kmConversionFactor);
46 | }
47 |
48 | // A helper to convert from miles to km
49 | func MilesToKm(miles float32) float32 {
50 | milesConversionFactor := 1.609344
51 | return miles * float32(milesConversionFactor);
52 | }
53 |
54 |
55 |
56 | // A Util function to generate jwt_token which can be used in the request header
57 | func GenToken(id string, role db.Role) (string, string) {
58 | jwt_token := jwt.New(jwt.GetSigningMethod("HS256"))
59 | // Set some claims
60 | jwt_token.Claims = jwt.MapClaims{
61 | "id": id,
62 | "role": role,
63 | "exp": time.Now().Add(time.Hour * 24 * 3).Unix(),
64 | }
65 | // Sign and get the complete encoded token as a string
66 | token, _ := jwt_token.SignedString([]byte(os.Getenv("JWT_SECRET")))
67 |
68 | refreshToken := jwt.New(jwt.SigningMethodHS256)
69 | rtClaims := refreshToken.Claims.(jwt.MapClaims)
70 | rtClaims["id"] = id
71 | rtClaims["exp"] = time.Now().Add(time.Hour * 24 * 20).Unix()
72 |
73 | rt, _ := refreshToken.SignedString([]byte(os.Getenv("JWT_SECRET")))
74 |
75 | return token, rt
76 | }
77 |
78 | // My own Error type that will help return my customized Error info
79 | // {"database": {"hello":"no such table", error: "not_exists"}}
80 | type CommonError struct {
81 | Errors map[string]interface{} `json:"errors"`
82 | }
83 |
84 | // To handle the error returned by c.Bind in gin framework
85 | // https://github.com/go-playground/validator/blob/v9/_examples/translations/main.go
86 | func NewValidatorError(err error) CommonError {
87 | res := CommonError{}
88 | res.Errors = make(map[string]interface{})
89 | errs := err.(validator.ValidationErrors)
90 | for _, v := range errs {
91 | // can translate each error one at a time.
92 | //fmt.Println("gg",v.NameNamespace)
93 | if v.Param() != "" {
94 | res.Errors[v.Field()] = fmt.Sprintf("{%v: %v}", v.Tag(), v.Param())
95 | } else {
96 | res.Errors[v.Field()] = fmt.Sprintf("{key: %v}", v.Tag())
97 | }
98 |
99 | }
100 | return res
101 | }
102 |
103 | // Warp the error info in a object
104 | func NewError(key string, err error) CommonError {
105 | res := CommonError{}
106 | res.Errors = make(map[string]interface{})
107 | res.Errors[key] = err.Error()
108 | return res
109 | }
110 |
111 | // Changed the c.MustBindWith() -> c.ShouldBindWith().
112 | // I don't want to auto return 400 when error happened.
113 | // origin function is here: https://github.com/gin-gonic/gin/blob/master/context.go
114 | func Bind(c *gin.Context, obj interface{}) error {
115 | b := binding.Default(c.Request.Method, c.ContentType())
116 | return c.ShouldBindWith(obj, b)
117 | }
118 |
--------------------------------------------------------------------------------
/ui/src/components/_base-link.unit.js:
--------------------------------------------------------------------------------
1 | import BaseLink from './_base-link.vue'
2 |
3 | const mountBaseLink = (options = {}) => {
4 | return mount(BaseLink, {
5 | stubs: {
6 | RouterLink: {
7 | functional: true,
8 | render(h, { slots, data }) {
9 | return {slots().default}
10 | },
11 | },
12 | },
13 | slots: {
14 | default: 'hello',
15 | },
16 | ...options,
17 | })
18 | }
19 |
20 | describe('@components/_base-link', () => {
21 | const originalConsoleWarn = global.console.warn
22 | let warning
23 | beforeEach(() => {
24 | warning = undefined
25 | global.console.warn = jest.fn((text) => {
26 | warning = text
27 | })
28 | })
29 | afterAll(() => {
30 | global.console.warn = originalConsoleWarn
31 | })
32 |
33 | it('exports a valid component', () => {
34 | expect(BaseLink).toBeAComponent()
35 | })
36 |
37 | it('warns about missing required props', () => {
38 | mountBaseLink()
39 | expect(console.warn).toHaveBeenCalledTimes(1)
40 | expect(warning).toMatch(/Invalid props/)
41 | })
42 |
43 | it('warns about an invalid href', () => {
44 | mountBaseLink({
45 | propsData: {
46 | href: '/some/local/path',
47 | },
48 | })
49 | expect(console.warn).toHaveBeenCalledTimes(1)
50 | expect(warning).toMatch(/Invalid href/)
51 | })
52 |
53 | it('warns about an insecure href', () => {
54 | mountBaseLink({
55 | propsData: {
56 | href: 'http://google.com',
57 | },
58 | })
59 | expect(console.warn).toHaveBeenCalledTimes(1)
60 | expect(warning).toMatch(/Insecure href/)
61 | })
62 |
63 | it('renders an anchor element when passed an `href` prop', () => {
64 | const externalUrl = 'https://google.com/'
65 | const { element } = mountBaseLink({
66 | propsData: {
67 | href: externalUrl,
68 | },
69 | })
70 | expect(console.warn).not.toHaveBeenCalled()
71 | expect(element.tagName).toEqual('A')
72 | expect(element.href).toEqual(externalUrl)
73 | expect(element.target).toEqual('_blank')
74 | expect(element.textContent).toEqual('hello')
75 | })
76 |
77 | it('renders a RouterLink when passed a `name` prop', () => {
78 | const routeName = 'home'
79 | const { element, vm } = mountBaseLink({
80 | propsData: {
81 | name: routeName,
82 | },
83 | })
84 | expect(console.warn).not.toHaveBeenCalled()
85 | expect(element.dataset.routerLink).toEqual('true')
86 | expect(element.textContent).toEqual('hello')
87 | expect(vm.routerLinkTo).toEqual({ name: routeName, params: {} })
88 | })
89 |
90 | it('renders a RouterLink when passed `name` and `params` props', () => {
91 | const routeName = 'home'
92 | const routeParams = { foo: 'bar' }
93 | const { element, vm } = mountBaseLink({
94 | propsData: {
95 | name: routeName,
96 | params: routeParams,
97 | },
98 | })
99 | expect(console.warn).not.toHaveBeenCalled()
100 | expect(element.dataset.routerLink).toEqual('true')
101 | expect(element.textContent).toEqual('hello')
102 | expect(vm.routerLinkTo).toEqual({
103 | name: routeName,
104 | params: routeParams,
105 | })
106 | })
107 |
108 | it('renders a RouterLink when passed a `to` prop', () => {
109 | const routeName = 'home'
110 | const { element, vm } = mountBaseLink({
111 | propsData: {
112 | to: {
113 | name: routeName,
114 | },
115 | },
116 | })
117 | expect(console.warn).not.toHaveBeenCalled()
118 | expect(element.dataset.routerLink).toEqual('true')
119 | expect(element.textContent).toEqual('hello')
120 | expect(vm.routerLinkTo).toEqual({ name: routeName, params: {} })
121 | })
122 |
123 | it('renders a RouterLink when passed a `to` prop with `params`', () => {
124 | const routeName = 'home'
125 | const routeParams = { foo: 'bar' }
126 | const { element, vm } = mountBaseLink({
127 | propsData: {
128 | to: {
129 | name: routeName,
130 | params: routeParams,
131 | },
132 | },
133 | })
134 | expect(console.warn).not.toHaveBeenCalled()
135 | expect(element.dataset.routerLink).toEqual('true')
136 | expect(element.textContent).toEqual('hello')
137 | expect(vm.routerLinkTo).toEqual({ name: routeName, params: routeParams })
138 | })
139 | })
140 |
--------------------------------------------------------------------------------
/server/service/fuellyImportService.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "bytes"
5 | "encoding/csv"
6 | "fmt"
7 | "strconv"
8 | "time"
9 |
10 | "hammond/db"
11 |
12 | "github.com/leekchan/accounting"
13 | )
14 |
15 | func FuellyParseAll(content []byte, userId string) ([]db.Fillup, []db.Expense, []string) {
16 | stream := bytes.NewReader(content)
17 | reader := csv.NewReader(stream)
18 | records, err := reader.ReadAll()
19 |
20 | var errors []string
21 | user, err := GetUserById(userId)
22 | if err != nil {
23 | errors = append(errors, err.Error())
24 | return nil, nil, errors
25 | }
26 |
27 | vehicles, err := GetUserVehicles(userId)
28 | if err != nil {
29 | errors = append(errors, err.Error())
30 | return nil, nil, errors
31 | }
32 |
33 | if err != nil {
34 | errors = append(errors, err.Error())
35 | return nil, nil, errors
36 | }
37 |
38 | var vehicleMap map[string]db.Vehicle = make(map[string]db.Vehicle)
39 | for _, vehicle := range *vehicles {
40 | vehicleMap[vehicle.Nickname] = vehicle
41 | }
42 |
43 | var fillups []db.Fillup
44 | var expenses []db.Expense
45 | layout := "2006-01-02 15:04"
46 | altLayout := "2006-01-02 3:04 PM"
47 |
48 | for index, record := range records {
49 | if index == 0 {
50 | continue
51 | }
52 |
53 | var vehicle db.Vehicle
54 | var ok bool
55 | if vehicle, ok = vehicleMap[record[4]]; !ok {
56 | errors = append(errors, "Found an unmapped vehicle entry at row "+strconv.Itoa(index+1))
57 | }
58 | dateStr := record[2] + " " + record[3]
59 | date, err := time.Parse(layout, dateStr)
60 | if err != nil {
61 | date, err = time.Parse(altLayout, dateStr)
62 | }
63 | if err != nil {
64 | errors = append(errors, "Found an invalid date/time at row "+strconv.Itoa(index+1))
65 | }
66 |
67 | totalCostStr := accounting.UnformatNumber(record[9], 3, user.Currency)
68 | totalCost64, err := strconv.ParseFloat(totalCostStr, 32)
69 | if err != nil {
70 | errors = append(errors, "Found an invalid total cost at row "+strconv.Itoa(index+1))
71 | }
72 |
73 | totalCost := float32(totalCost64)
74 | odoStr := accounting.UnformatNumber(record[5], 0, user.Currency)
75 | odoreading, err := strconv.Atoi(odoStr)
76 | if err != nil {
77 | errors = append(errors, "Found an invalid odo reading at row "+strconv.Itoa(index+1))
78 | }
79 | location := record[12]
80 |
81 | //Create Fillup
82 | if record[0] == "Gas" {
83 | rateStr := accounting.UnformatNumber(record[7], 3, user.Currency)
84 | ratet64, err := strconv.ParseFloat(rateStr, 32)
85 | if err != nil {
86 | errors = append(errors, "Found an invalid cost per gallon at row "+strconv.Itoa(index+1))
87 | }
88 | rate := float32(ratet64)
89 |
90 | quantity64, err := strconv.ParseFloat(record[8], 32)
91 | if err != nil {
92 | errors = append(errors, "Found an invalid quantity at row "+strconv.Itoa(index+1))
93 | }
94 | quantity := float32(quantity64)
95 |
96 | notes := fmt.Sprintf("Octane:%s\nGas Brand:%s\nLocation%s\nTags:%s\nPayment Type:%s\nTire Pressure:%s\nNotes:%s\nMPG:%s",
97 | record[10], record[11], record[12], record[13], record[14], record[15], record[16], record[1],
98 | )
99 |
100 | isTankFull := record[6] == "Full"
101 | fal := false
102 | fillups = append(fillups, db.Fillup{
103 | VehicleID: vehicle.ID,
104 | FuelUnit: vehicle.FuelUnit,
105 | FuelQuantity: quantity,
106 | PerUnitPrice: rate,
107 | TotalAmount: totalCost,
108 | OdoReading: odoreading,
109 | IsTankFull: &isTankFull,
110 | Comments: notes,
111 | FillingStation: location,
112 | HasMissedFillup: &fal,
113 | UserID: userId,
114 | Date: date,
115 | Currency: user.Currency,
116 | DistanceUnit: user.DistanceUnit,
117 | Source: "Fuelly",
118 | })
119 |
120 | }
121 | if record[0] == "Service" {
122 | notes := fmt.Sprintf("Tags:%s\nPayment Type:%s\nNotes:%s",
123 | record[13], record[14], record[16],
124 | )
125 | expenses = append(expenses, db.Expense{
126 | VehicleID: vehicle.ID,
127 | Amount: totalCost,
128 | OdoReading: odoreading,
129 | Comments: notes,
130 | ExpenseType: record[17],
131 | UserID: userId,
132 | Currency: user.Currency,
133 | Date: date,
134 | DistanceUnit: user.DistanceUnit,
135 | Source: "Fuelly",
136 | })
137 | }
138 |
139 | }
140 | return fillups, expenses, errors
141 | }
142 |
--------------------------------------------------------------------------------
/docs/ubuntu-install.md:
--------------------------------------------------------------------------------
1 | # Building from source / Ubuntu Installation Guide
2 |
3 | Although personally I feel that using the docker container is the best way of using and enjoying something like Hammond, a lot of people in the community are still not comfortable with using Docker and wanted to host it natively on their Linux servers.
4 |
5 | This guide has been written with Ubuntu in mind. If you are using any other flavour of Linux and are decently competent with using command line tools, it should be easy to figure out the steps for your specific distro.
6 |
7 | ## Install Go and Node
8 |
9 | Hammond is built using Go and VueJS which means GO and Node would be needed to compile and build the source code. Hammond is written with Go 1.15/ Node v14 so any version equal to or above this should be good to go.
10 |
11 | If you already have Go and Node installed on your machine, you can skip to the next step.
12 |
13 | Get precise Go installation process at the official link here - https://golang.org/doc/install
14 |
15 | Get precise Node installation process at the official link here - https://nodejs.org/en/
16 |
17 |
18 | Following steps will only work if Go and Node are installed and configured properly.
19 |
20 | ## Install dependencies
21 |
22 | ``` bash
23 | sudo apt-get install -y git ca-certificates ufw gcc
24 | ```
25 |
26 | ## Clone from Git
27 |
28 | ``` bash
29 | git clone --depth 1 https://github.com/alfhou/hammond
30 | ```
31 |
32 | ## Build and Copy dependencies
33 |
34 | ``` bash
35 | cd hammond/server
36 | mkdir -p ./dist
37 | cp .env ./dist
38 | go build -o ./dist/hammond ./main.go
39 | ```
40 |
41 | ## Create final destination and copy executable
42 | ``` bash
43 | sudo mkdir -p /usr/local/bin/hammond
44 | mv -v dist/* /usr/local/bin/hammond
45 | mv -v dist/.* /usr/local/bin/hammond
46 | ```
47 |
48 |
49 | ## Building the UI
50 |
51 | Go back to the root of the hammond folder.
52 |
53 | ``` bash
54 | cd ui
55 | npm install
56 | npm run build
57 | mv dist /usr/local/bin/hammond
58 | ```
59 |
60 | At this point theoretically the installation is complete. You can make the relevant changes in the ```.env``` file present at ```/usr/local/bin/hammond``` path and run the following command
61 |
62 | ``` bash
63 | cd /usr/local/bin/hammond && ./hammond
64 | ```
65 |
66 | Point your browser to http://localhost:3000 (if trying on the same machine) or http://server-ip:3000 from other machines.
67 |
68 | If you are using ufw or some other firewall, you might have to make an exception for this port on that.
69 |
70 | ## Setup as service (Optional)
71 |
72 | If you want to run Hammond in the background as a service or auto-start whenever the server starts, follow the next steps.
73 |
74 | Create new file named ```hammond.service``` at ```/etc/systemd/system``` and add the following content. You will have to modify the content accordingly if you changed the installation path in the previous steps.
75 |
76 |
77 | ``` unit
78 | [Unit]
79 | Description=Hammond
80 |
81 | [Service]
82 | ExecStart=/usr/local/bin/hammond/hammond
83 | WorkingDirectory=/usr/local/bin/hammond/
84 | [Install]
85 | WantedBy=multi-user.target
86 | ```
87 |
88 | Run the following commands
89 | ``` bash
90 | sudo systemctl daemon-reload
91 | sudo systemctl enable hammond.service
92 | sudo systemctl start hammond.service
93 | ```
94 |
95 | Run the following command to check the service status.
96 |
97 | ``` bash
98 | sudo systemctl status hammond.service
99 | ```
100 |
101 | # Update Hammond
102 |
103 | In case you have installed Hammond and want to update the latest version (another area where Docker really shines) you need to repeat the steps from cloning to building and copying.
104 |
105 | Stop the running service (if using)
106 | ``` bash
107 | sudo systemctl stop hammond.service
108 | ```
109 |
110 | ## Clone from Git
111 |
112 | ``` bash
113 | git clone --depth 1 https://github.com/alfhou/hammond
114 | ```
115 |
116 | ## Build and Copy dependencies
117 |
118 | ``` bash
119 | cd hammond
120 | mkdir -p ./dist
121 | cp .env ./dist
122 | go build -o ./dist/hammond ./main.go
123 | ```
124 |
125 | ## Create final destination and copy executable
126 | ``` bash
127 | sudo mkdir -p /usr/local/bin/hammond
128 | mv -v dist/* /usr/local/bin/hammond
129 | ```
130 |
131 | Go back to the root of the hammond folder.
132 |
133 | ``` bash
134 | cd ui
135 | npm install
136 | npm run build
137 | mv dist /usr/local/bin/hammond
138 | ```
139 |
140 | Restart the service (if using)
141 | ``` bash
142 | sudo systemctl start hammond.service
143 | ```
144 |
--------------------------------------------------------------------------------
/ui/src/state/modules/auth.unit.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import * as authModule from './auth'
3 |
4 | describe('@state/modules/auth', () => {
5 | it('exports a valid Vuex module', () => {
6 | expect(authModule).toBeAVuexModule()
7 | })
8 |
9 | describe('in a store', () => {
10 | let store
11 | beforeEach(() => {
12 | store = createModuleStore(authModule)
13 | window.localStorage.clear()
14 | })
15 |
16 | it('mutations.SET_CURRENT_USER correctly sets axios default authorization header', () => {
17 | axios.defaults.headers.common.Authorization = ''
18 |
19 | store.commit('SET_CURRENT_USER', { token: 'some-token' })
20 | expect(axios.defaults.headers.common.Authorization).toEqual('some-token')
21 |
22 | store.commit('SET_CURRENT_USER', null)
23 | expect(axios.defaults.headers.common.Authorization).toEqual('')
24 | })
25 |
26 | it('mutations.SET_CURRENT_USER correctly saves currentUser in localStorage', () => {
27 | let savedCurrentUser = JSON.parse(
28 | window.localStorage.getItem('auth.currentUser')
29 | )
30 | expect(savedCurrentUser).toEqual(null)
31 |
32 | const expectedCurrentUser = { token: 'some-token' }
33 | store.commit('SET_CURRENT_USER', expectedCurrentUser)
34 |
35 | savedCurrentUser = JSON.parse(
36 | window.localStorage.getItem('auth.currentUser')
37 | )
38 | expect(savedCurrentUser).toEqual(expectedCurrentUser)
39 | })
40 |
41 | it('getters.loggedIn returns true when currentUser is an object', () => {
42 | store.commit('SET_CURRENT_USER', {})
43 | expect(store.getters.loggedIn).toEqual(true)
44 | })
45 |
46 | it('getters.loggedIn returns false when currentUser is null', () => {
47 | store.commit('SET_CURRENT_USER', null)
48 | expect(store.getters.loggedIn).toEqual(false)
49 | })
50 |
51 | it('actions.logIn resolves to a refreshed currentUser when already logged in', () => {
52 | expect.assertions(2)
53 |
54 | store.commit('SET_CURRENT_USER', { token: validUserExample.token })
55 | return store.dispatch('logIn').then((user) => {
56 | expect(user).toEqual(validUserExample)
57 | expect(store.state.currentUser).toEqual(validUserExample)
58 | })
59 | })
60 |
61 | it('actions.logIn commits the currentUser and resolves to the user when NOT already logged in and provided a correct username and password', () => {
62 | expect.assertions(2)
63 |
64 | return store
65 | .dispatch('logIn', { username: 'admin', password: 'password' })
66 | .then((user) => {
67 | expect(user).toEqual(validUserExample)
68 | expect(store.state.currentUser).toEqual(validUserExample)
69 | })
70 | })
71 |
72 | it('actions.logIn rejects with 401 when NOT already logged in and provided an incorrect username and password', () => {
73 | expect.assertions(1)
74 |
75 | return store
76 | .dispatch('logIn', {
77 | username: 'bad username',
78 | password: 'bad password',
79 | })
80 | .catch((error) => {
81 | expect(error.message).toEqual('Request failed with status code 401')
82 | })
83 | })
84 |
85 | it('actions.validate resolves to null when currentUser is null', () => {
86 | expect.assertions(1)
87 |
88 | store.commit('SET_CURRENT_USER', null)
89 | return store.dispatch('validate').then((user) => {
90 | expect(user).toEqual(null)
91 | })
92 | })
93 |
94 | it('actions.validate resolves to null when currentUser contains an invalid token', () => {
95 | expect.assertions(2)
96 |
97 | store.commit('SET_CURRENT_USER', { token: 'invalid-token' })
98 | return store.dispatch('validate').then((user) => {
99 | expect(user).toEqual(null)
100 | expect(store.state.currentUser).toEqual(null)
101 | })
102 | })
103 |
104 | it('actions.validate resolves to a user when currentUser contains a valid token', () => {
105 | expect.assertions(2)
106 |
107 | store.commit('SET_CURRENT_USER', { token: validUserExample.token })
108 | return store.dispatch('validate').then((user) => {
109 | expect(user).toEqual(validUserExample)
110 | expect(store.state.currentUser).toEqual(validUserExample)
111 | })
112 | })
113 | })
114 | })
115 |
116 | const validUserExample = {
117 | id: 1,
118 | username: 'admin',
119 | name: 'Vue Master',
120 | token: 'valid-token-for-admin',
121 | }
122 |
--------------------------------------------------------------------------------
/server/service/drivvoImportService.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "bytes"
5 | "encoding/csv"
6 | "fmt"
7 | "strconv"
8 | "strings"
9 | "time"
10 |
11 | "hammond/db"
12 | )
13 |
14 | func DrivvoParseExpenses(content []byte, user *db.User, vehicle *db.Vehicle) ([]db.Expense, []string) {
15 | expenseReader := csv.NewReader(bytes.NewReader(content))
16 | expenseReader.Comment = '#'
17 | // Read headers (there is a trailing comma at the end, that's why we have to read the first line)
18 | expenseReader.Read()
19 | expenseReader.FieldsPerRecord = 6
20 | expenseRecords, err := expenseReader.ReadAll()
21 |
22 | var errors []string
23 | if err != nil {
24 | errors = append(errors, err.Error())
25 | println(err.Error())
26 | return nil, errors
27 | }
28 |
29 | var expenses []db.Expense
30 | for index, record := range expenseRecords {
31 | date, err := time.Parse("2006-01-02 15:04:05", record[1])
32 | if err != nil {
33 | errors = append(errors, "Found an invalid date/time at service/expense row "+strconv.Itoa(index+1))
34 | }
35 |
36 | totalCost, err := strconv.ParseFloat(record[2], 32)
37 | if err != nil {
38 | errors = append(errors, "Found and invalid total cost at service/expense row "+strconv.Itoa(index+1))
39 | }
40 |
41 | odometer, err := strconv.Atoi(record[0])
42 | if err != nil {
43 | errors = append(errors, "Found an invalid odometer reading at service/expense row "+strconv.Itoa(index+1))
44 | }
45 |
46 | notes := fmt.Sprintf("Location: %s\nNotes: %s\n", record[4], record[5])
47 |
48 | expenses = append(expenses, db.Expense{
49 | UserID: user.ID,
50 | VehicleID: vehicle.ID,
51 | Date: date,
52 | OdoReading: odometer,
53 | Amount: float32(totalCost),
54 | ExpenseType: record[3],
55 | Currency: user.Currency,
56 | DistanceUnit: user.DistanceUnit,
57 | Comments: notes,
58 | Source: "Drivvo",
59 | })
60 | }
61 |
62 | return expenses, errors
63 | }
64 |
65 | func DrivvoParseRefuelings(content []byte, user *db.User, vehicle *db.Vehicle, importLocation bool) ([]db.Fillup, []string) {
66 | refuelingReader := csv.NewReader(bytes.NewReader(content))
67 | refuelingReader.Comment = '#'
68 | refuelingRecords, err := refuelingReader.ReadAll()
69 |
70 | var errors []string
71 | if err != nil {
72 | errors = append(errors, err.Error())
73 | println(err.Error())
74 | return nil, errors
75 | }
76 |
77 | var fillups []db.Fillup
78 | for index, record := range refuelingRecords {
79 | // Skip column titles
80 | if index == 0 {
81 | continue
82 | }
83 |
84 | date, err := time.Parse("2006-01-02 15:04:05", record[1])
85 | if err != nil {
86 | errors = append(errors, "Found an invalid date/time at refuel row "+strconv.Itoa(index+1))
87 | }
88 |
89 | totalCost, err := strconv.ParseFloat(record[4], 32)
90 | if err != nil {
91 | errors = append(errors, "Found and invalid total cost at refuel row "+strconv.Itoa(index+1))
92 | }
93 |
94 | odometer, err := strconv.Atoi(record[0])
95 | if err != nil {
96 | errors = append(errors, "Found an invalid odometer reading at refuel row "+strconv.Itoa(index+1))
97 | }
98 |
99 | location := ""
100 | if importLocation {
101 | location = record[17]
102 | }
103 |
104 | pricePerUnit, err := strconv.ParseFloat(record[3], 32)
105 | if err != nil {
106 | unit := strings.ToLower(db.FuelUnitDetails[vehicle.FuelUnit].Key)
107 | errors = append(errors, fmt.Sprintf("Found an invalid cost per %s at refuel row %d", unit, index+1))
108 | }
109 |
110 | quantity, err := strconv.ParseFloat(record[5], 32)
111 | if err != nil {
112 | errors = append(errors, "Found an invalid quantity at refuel row "+strconv.Itoa(index+1))
113 | }
114 |
115 | isTankFull := record[6] == "Yes"
116 |
117 | // Unfortunatly, drivvo doesn't expose this info in their export
118 | fal := false
119 |
120 | notes := fmt.Sprintf("Reason: %s\nNotes: %s\nFuel: %s\n", record[18], record[19], record[2])
121 |
122 | fillups = append(fillups, db.Fillup{
123 | VehicleID: vehicle.ID,
124 | UserID: user.ID,
125 | Date: date,
126 | HasMissedFillup: &fal,
127 | IsTankFull: &isTankFull,
128 | FuelQuantity: float32(quantity),
129 | PerUnitPrice: float32(pricePerUnit),
130 | FillingStation: location,
131 | OdoReading: odometer,
132 | TotalAmount: float32(totalCost),
133 | FuelUnit: vehicle.FuelUnit,
134 | Currency: user.Currency,
135 | DistanceUnit: user.DistanceUnit,
136 | Comments: notes,
137 | Source: "Drivvo",
138 | })
139 |
140 | }
141 | return fillups, errors
142 | }
143 |
--------------------------------------------------------------------------------
/ui/src/router/views/quickEntries.vue:
--------------------------------------------------------------------------------
1 |
59 |
60 |
61 |
62 | {{ $tc('quickentry', 2) }}
63 |
64 | {{ $t('showunprocessed') }}
65 |
66 |
67 |
68 |
69 |
70 |
76 |
77 |
78 |
![Placeholder image]()
84 |
85 |
88 |
100 |
101 |
102 |
103 |
104 |
105 |
{{ $tc('quickentry',0) }}
106 |
107 |
108 |
109 |
110 |
120 |
--------------------------------------------------------------------------------
/ui/src/utils/dispatch-action-for-all-modules.unit.js:
--------------------------------------------------------------------------------
1 | describe('@utils/dispatch-action-for-all-modules', () => {
2 | beforeEach(() => {
3 | jest.resetModules()
4 | })
5 |
6 | it('dispatches actions from NOT namespaced modules', () => {
7 | jest.doMock('@state/modules', () => ({
8 | moduleA: {
9 | actions: {
10 | someAction: jest.fn(),
11 | otherAction: jest.fn(),
12 | },
13 | },
14 | moduleB: {
15 | actions: {
16 | someAction: jest.fn(),
17 | otherAction: jest.fn(),
18 | },
19 | },
20 | }))
21 |
22 | require('./dispatch-action-for-all-modules').default('someAction')
23 |
24 | const { moduleA, moduleB } = require('@state/modules')
25 | expect(moduleA.actions.someAction).toHaveBeenCalledTimes(1)
26 | expect(moduleB.actions.someAction).toHaveBeenCalledTimes(1)
27 | expect(moduleA.actions.otherAction).not.toHaveBeenCalled()
28 | expect(moduleB.actions.otherAction).not.toHaveBeenCalled()
29 | })
30 |
31 | it('dispatches actions from namespaced modules', () => {
32 | jest.doMock('@state/modules', () => ({
33 | moduleA: {
34 | namespaced: true,
35 | actions: {
36 | someAction: jest.fn(),
37 | otherAction: jest.fn(),
38 | },
39 | },
40 | moduleB: {
41 | namespaced: true,
42 | actions: {
43 | someAction: jest.fn(),
44 | otherAction: jest.fn(),
45 | },
46 | },
47 | }))
48 |
49 | require('./dispatch-action-for-all-modules').default('someAction')
50 |
51 | const { moduleA, moduleB } = require('@state/modules')
52 | expect(moduleA.actions.someAction).toHaveBeenCalledTimes(1)
53 | expect(moduleB.actions.someAction).toHaveBeenCalledTimes(1)
54 | expect(moduleA.actions.otherAction).not.toHaveBeenCalled()
55 | expect(moduleB.actions.otherAction).not.toHaveBeenCalled()
56 | })
57 |
58 | it('dispatches actions from deeply nested NOT namespaced modules', () => {
59 | jest.doMock('@state/modules', () => ({
60 | moduleA: {
61 | actions: {
62 | someAction: jest.fn(),
63 | otherAction: jest.fn(),
64 | },
65 | modules: {
66 | moduleB: {
67 | actions: {
68 | someAction: jest.fn(),
69 | otherAction: jest.fn(),
70 | },
71 | modules: {
72 | moduleC: {
73 | actions: {
74 | someAction: jest.fn(),
75 | otherAction: jest.fn(),
76 | },
77 | },
78 | },
79 | },
80 | },
81 | },
82 | }))
83 |
84 | require('./dispatch-action-for-all-modules').default('someAction')
85 |
86 | const { moduleA } = require('@state/modules')
87 | const { moduleB } = moduleA.modules
88 | const { moduleC } = moduleB.modules
89 | expect(moduleA.actions.someAction).toHaveBeenCalledTimes(1)
90 | expect(moduleB.actions.someAction).toHaveBeenCalledTimes(1)
91 | expect(moduleC.actions.someAction).toHaveBeenCalledTimes(1)
92 | expect(moduleA.actions.otherAction).not.toHaveBeenCalled()
93 | expect(moduleB.actions.otherAction).not.toHaveBeenCalled()
94 | expect(moduleC.actions.otherAction).not.toHaveBeenCalled()
95 | })
96 |
97 | it('dispatches actions from deeply nested namespaced modules', () => {
98 | jest.doMock('@state/modules', () => ({
99 | moduleA: {
100 | namespaced: true,
101 | actions: {
102 | someAction: jest.fn(),
103 | otherAction: jest.fn(),
104 | },
105 | modules: {
106 | moduleB: {
107 | namespaced: true,
108 | actions: {
109 | someAction: jest.fn(),
110 | otherAction: jest.fn(),
111 | },
112 | modules: {
113 | moduleC: {
114 | namespaced: true,
115 | actions: {
116 | someAction: jest.fn(),
117 | otherAction: jest.fn(),
118 | },
119 | },
120 | },
121 | },
122 | },
123 | },
124 | }))
125 |
126 | require('./dispatch-action-for-all-modules').default('someAction')
127 |
128 | const { moduleA } = require('@state/modules')
129 | const { moduleB } = moduleA.modules
130 | const { moduleC } = moduleB.modules
131 | expect(moduleA.actions.someAction).toHaveBeenCalledTimes(1)
132 | expect(moduleB.actions.someAction).toHaveBeenCalledTimes(1)
133 | expect(moduleC.actions.someAction).toHaveBeenCalledTimes(1)
134 | expect(moduleA.actions.otherAction).not.toHaveBeenCalled()
135 | expect(moduleB.actions.otherAction).not.toHaveBeenCalled()
136 | expect(moduleC.actions.otherAction).not.toHaveBeenCalled()
137 | })
138 | })
139 |
--------------------------------------------------------------------------------