Compare commits

..

28 Commits

Author SHA1 Message Date
alfhouge
f9d24bc7ef Fix indentation in db migration 2023-01-19 14:52:43 +01:00
Alf Sebastian Houge
d3ce6920ad Merge branch 'master' into add-vin 2022-11-26 19:38:51 +01:00
Alf Sebastian Houge
afdfa31148 Add vscode specific files to gitignore 2022-11-26 19:24:44 +01:00
Alf Sebastian Houge
2d24c4b9e6 Merge pull request #1 from djjudas21/ci
Run Github actions on releases
2022-11-26 19:13:19 +01:00
Akhil Gupta
84cba2c7f2 Merge pull request #74 from AlfHou/case-insensitive-emails 2022-07-25 12:01:51 +05:30
Akhil Gupta
1432499a90 Merge pull request #101 from heikok88/bugfix/wrongStatsSums 2022-07-25 11:59:56 +05:30
Eikou
d0704c8c6a Fixed issue #87: adapted start date of 'this_week' and 'this_year' stats 2022-07-22 15:26:17 +02:00
Akhil Gupta
b86795bcb6 Merge pull request #71 from AlfHou/contributing-instructions
Add contributing instructions
2022-07-12 17:23:30 +05:30
Akhil Gupta
1ccdce9ee3 Merge pull request #89 from AlfHou/chore/fix-error-messages
Fix warnings reported by go-staticcheck
2022-07-12 17:23:07 +05:30
Akhil Gupta
5cfaf8c933 Merge branch 'master' of https://github.com/akhilrex/hammond 2022-07-06 11:00:13 +05:30
Akhil Gupta
987f035198 fix issue #93 2022-07-06 11:00:09 +05:30
Jonathan Gazeley
ab94997dd6 Add basic tests
Run release pipeline on tags
2022-05-18 21:50:25 +01:00
Alf Sebastian Houge
c00c6bc776 Map VIN number to db model from request model when creating vehicle 2022-04-28 17:36:13 +02:00
Akhil Gupta
a5d4dface8 Merge pull request #85 from AlfHou/bug/strip-login-whitespace 2022-04-26 16:32:10 +05:30
Akhil Gupta
7cb9a43dfe try disable cache 2022-04-26 14:26:21 +05:30
Akhil Gupta
05bb22fe4e update a couple of packages and bump version 2022-04-26 13:34:26 +05:30
Akhil Gupta
69352af906 build fixed 2022-04-26 12:59:31 +05:30
Akhil Gupta
7a8916c9cd fix file 2022-04-26 12:27:01 +05:30
Akhil Gupta
e471e80617 try fix breaking build 2022-04-26 12:25:33 +05:30
Akhil Gupta
1ee032b664 Merge pull request #86 from AlfHou/feat/currency-search
Make currency field searchable
2022-04-26 11:58:19 +05:30
Akhil Gupta
cea2566e2a Merge branch 'master' of https://github.com/akhilrex/hammond 2022-04-26 11:54:59 +05:30
Akhil Gupta
dcb58bbbdb some alerting code 2022-04-26 11:54:55 +05:30
Alf Sebastian Houge
43d1ca0c66 Tag login email field as email type 2022-04-17 18:53:15 +02:00
Alf Sebastian Houge
fb742f19a7 Change currency field from select to autocomplete 2022-04-17 18:52:19 +02:00
Alf Sebastian Houge
8410674841 Add vin field vehicle data 2022-03-08 13:49:11 +01:00
Alf Sebastian Houge
d9a99d432c Make emails case insensitive 2022-03-07 00:17:35 +01:00
Alf Sebastian Houge
acba47fede Add contributing instructions 2022-03-05 19:21:53 +01:00
Akhil Gupta
fca2c3e7fa start alerts 2021-09-03 15:46:39 +05:30
24 changed files with 1569 additions and 505 deletions

View File

@@ -1,45 +1,46 @@
name: ci name: Build docker image
on: on:
push: release:
branches: master types: [published]
jobs: jobs:
multi: multi:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- - name: Checkout
name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2
- - name: Set up QEMU
name: Set up QEMU id: qemu
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v1
- with:
name: Set up Docker Buildx platforms: linux/amd64,linux/arm64,linux/arm/v7
- name: Available platforms
run: echo ${{ steps.qemu.outputs.platforms }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v1
- - name: Set up build cache
name: Set up build cache
uses: actions/cache@v2 uses: actions/cache@v2
with: with:
path: /tmp/.buildx-cache path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }} key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: | restore-keys: |
${{ runner.os }}-buildx- ${{ runner.os }}-buildx-
- - name: Parse the git tag
name: Login to DockerHub id: get_tag
run: echo ::set-output name=TAG::$(echo $GITHUB_REF | cut -d / -f 3)
- name: Login to DockerHub
uses: docker/login-action@v1 uses: docker/login-action@v1
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- - name: Login to GitHub
name: Login to GitHub
uses: docker/login-action@v1 uses: docker/login-action@v1
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.CR_PAT }} password: ${{ secrets.CR_PAT }}
- - name: Build and push
name: Build and push
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
with: with:
context: . context: .
@@ -48,10 +49,10 @@ jobs:
#platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 #platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true push: true
cache-from: type=local,src=/tmp/.buildx-cache # cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache # cache-to: type=local,dest=/tmp/.buildx-cache
tags: | tags: |
akhilrex/hammond:latest akhilrex/hammond:latest
akhilrex/hammond:1.0.0 akhilrex/hammond:${{ steps.get_tag.outputs.TAG }}
ghcr.io/akhilrex/hammond:latest ghcr.io/akhilrex/hammond:latest
ghcr.io/akhilrex/hammond:1.0.0 ghcr.io/akhilrex/hammond:${{ steps.get_tag.outputs.TAG }}

16
.github/workflows/test-go.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
on: [push, pull_request]
name: Test server
jobs:
test:
strategy:
matrix:
go-version: [1.17.x, 1.18.x]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go-version }}
- uses: actions/checkout@v3
- run: go test ./...
working-directory: server

22
.github/workflows/test-npm.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Test UI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [10.x, 12.x, 14.x, 15.x]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
working-directory: ui
- run: npm run build --if-present
working-directory: ui
- run: npm test
working-directory: ui

View File

@@ -9,7 +9,7 @@ RUN go mod download
COPY ./server . COPY ./server .
RUN go build -o ./app ./main.go RUN go build -o ./app ./main.go
FROM node:latest as build-stage FROM node:14 as build-stage
WORKDIR /app WORKDIR /app
COPY ./ui/package*.json ./ COPY ./ui/package*.json ./
RUN npm install RUN npm install

View File

@@ -8,7 +8,7 @@
</a> --> </a> -->
<h1 align="center" style="margin-bottom:0">Hammond</h1> <h1 align="center" style="margin-bottom:0">Hammond</h1>
<p align="center">Current Version - 2021.09.20</p> <p align="center">Current Version - 2022.07.06</p>
<p align="center"> <p align="center">
A self-hosted vehicle expense tracking system with support for multiple users. A self-hosted vehicle expense tracking system with support for multiple users.
@@ -35,6 +35,7 @@
- [Built With](#built-with) - [Built With](#built-with)
- [Features](#features) - [Features](#features)
- [Installation](#installation) - [Installation](#installation)
- [Contributing](#contributing)
- [License](#license) - [License](#license)
- [Roadmap](#roadmap) - [Roadmap](#roadmap)
- [Contact](#contact) - [Contact](#contact)
@@ -157,6 +158,31 @@ Once done you will be taken to the login page.
Go through the settings page once and change relevant settings before you start adding vehicles and expenses. Go through the settings page once and change relevant settings before you start adding vehicles and expenses.
## Contributing
### Dev Setup
If you want to contribute to the project you need to set it up
for development first.
Fork and clone the project. Once you have it on your own machine,
open up a terminal and navigate to the `server/` directory.
In the `server/` directory run the command `go run main.go`.
After some initial
setup, the server should be listening on at port `3000`.
Next, open a new terminal. Navigate to the `ui/` directory and run `npm install`.
This will install all the dependencies for the frontend.
After the command is done running, run `npm run dev`. After some output, the
frontend should be accessible at `http://localhost:8080`.
If you are sent straight to the login screen, try closing the page and opening
it again. You should be greeted with a setup wizard the first time you run the
project.
Now, simply follow the instructions in order to set up your fresh install.
## License ## License
Distributed under the GPL-3.0 License. See `LICENSE` for more information. Distributed under the GPL-3.0 License. See `LICENSE` for more information.

4
server/.gitignore vendored
View File

@@ -12,6 +12,10 @@
*.out *.out
*.db *.db
# MS VSCode
.vscode
__debug_bin
# Dependency directories (remove the comment below to include it) # Dependency directories (remove the comment below to include it)
# vendor/ # vendor/
assets/* assets/*

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"strings"
"github.com/akhilrex/hammond/common" "github.com/akhilrex/hammond/common"
"github.com/akhilrex/hammond/db" "github.com/akhilrex/hammond/db"
@@ -91,7 +92,7 @@ func userLogin(c *gin.Context) {
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err)) c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
return return
} }
user, err := db.FindOneUser(&db.User{Email: loginRequest.Email}) user, err := db.FindOneUser(&db.User{Email: strings.ToLower(loginRequest.Email)})
if err != nil { if err != nil {
c.JSON(http.StatusForbidden, common.NewError("login", errors.New("not Registered email or invalid password"))) c.JSON(http.StatusForbidden, common.NewError("login", errors.New("not Registered email or invalid password")))

View File

@@ -60,6 +60,7 @@ type Vehicle struct {
Base Base
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Registration string `json:"registration"` Registration string `json:"registration"`
VIN string `json:"vin"`
Make string `json:"make"` Make string `json:"make"`
Model string `json:"model"` Model string `json:"model"`
YearOfManufacture int `json:"yearOfManufacture"` YearOfManufacture int `json:"yearOfManufacture"`
@@ -195,3 +196,50 @@ type VehicleAttachment struct {
VehicleID string `gorm:"primaryKey" json:"vehicleId"` VehicleID string `gorm:"primaryKey" json:"vehicleId"`
Title string `json:"title"` Title string `json:"title"`
} }
type VehicleAlert struct {
Base
VehicleID string `json:"vehicleId"`
Vehicle Vehicle `json:"-"`
UserID string `json:"userId"`
User User `json:"user"`
Title string `json:"title"`
Comments string `json:"comments"`
StartDate time.Time `json:"date"`
StartOdoReading int `json:"startOdoReading"`
DistanceUnit DistanceUnit `json:"distanceUnit"`
AlertFrequency AlertFrequency `json:"alertFrequency"`
OdoFrequency int `json:"odoFrequency"`
DayFrequency int `json:"dayFrequency"`
AlertAllUsers bool `json:"alertAllUsers"`
IsActive bool `json:"isActive"`
EndDate *time.Time `json:"endDate"`
AlertType AlertType `json:"alertType"`
}
type AlertOccurance struct {
Base
VehicleID string `json:"vehicleId"`
Vehicle Vehicle `json:"-"`
VehicleAlertID string `json:"vehicleAlertId"`
VehicleAlert VehicleAlert `json:"-"`
UserID string `json:"userId"`
User User `json:"-"`
OdoReading int `json:"odoReading"`
Date *time.Time `json:"date"`
ProcessDate *time.Time `json:"processDate"`
AlertProcessType AlertType `json:"alertProcessType"`
CompleteDate *time.Time `json:"completeDate"`
}
type Notification struct {
Base
Title string `json:"title"`
Content string `json:"content"`
UserID string `json:"userId"`
VehicleID string `json:"vehicleId"`
User User `json:"-"`
Date time.Time `json:"date"`
ReadDate *time.Time `json:"readDate"`
ParentID string `json:"parentId"`
ParentType string `json:"parentType"`
}

View File

@@ -160,6 +160,11 @@ func GetFillupsByVehicleId(id string) (*[]Fillup, error) {
result := DB.Preload(clause.Associations).Order("date desc").Find(&obj, &Fillup{VehicleID: id}) result := DB.Preload(clause.Associations).Order("date desc").Find(&obj, &Fillup{VehicleID: id})
return &obj, result.Error return &obj, result.Error
} }
func GetLatestFillupsByVehicleId(id string) (*Fillup, error) {
var obj Fillup
result := DB.Preload(clause.Associations).Order("date desc").First(&obj, &Fillup{VehicleID: id})
return &obj, result.Error
}
func GetFillupsByVehicleIdSince(id string, since time.Time) (*[]Fillup, error) { func GetFillupsByVehicleIdSince(id string, since time.Time) (*[]Fillup, error) {
var obj []Fillup var obj []Fillup
result := DB.Where("date >= ? AND vehicle_id = ?", since, id).Preload(clause.Associations).Order("date desc").Find(&obj) result := DB.Where("date >= ? AND vehicle_id = ?", since, id).Preload(clause.Associations).Order("date desc").Find(&obj)
@@ -190,6 +195,11 @@ func GetExpensesByVehicleId(id string) (*[]Expense, error) {
result := DB.Preload(clause.Associations).Order("date desc").Find(&obj, &Expense{VehicleID: id}) result := DB.Preload(clause.Associations).Order("date desc").Find(&obj, &Expense{VehicleID: id})
return &obj, result.Error return &obj, result.Error
} }
func GetLatestExpenseByVehicleId(id string) (*Expense, error) {
var obj Expense
result := DB.Preload(clause.Associations).Order("date desc").First(&obj, &Expense{VehicleID: id})
return &obj, result.Error
}
func GetExpenseById(id string) (*Expense, error) { func GetExpenseById(id string) (*Expense, error) {
var obj Expense var obj Expense
result := DB.Preload(clause.Associations).First(&obj, "id=?", id) result := DB.Preload(clause.Associations).First(&obj, "id=?", id)
@@ -271,6 +281,29 @@ func GetVehicleAttachments(vehicleId string) (*[]Attachment, error) {
} }
return &attachments, nil return &attachments, nil
} }
func GeAlertById(id string) (*VehicleAlert, error) {
var alert VehicleAlert
result := DB.Preload(clause.Associations).First(&alert, "id=?", id)
return &alert, result.Error
}
func GetAlertOccurenceByAlertId(id string) (*[]AlertOccurance, error) {
var alertOccurance []AlertOccurance
result := DB.Preload(clause.Associations).Order("created_at desc").Find(&alertOccurance, "vehicle_alert_id=?", id)
return &alertOccurance, result.Error
}
func GetUnprocessedAlertOccurances() (*[]AlertOccurance, error) {
var alertOccurance []AlertOccurance
result := DB.Preload(clause.Associations).Order("created_at desc").Find(&alertOccurance, "process_date is NULL")
return &alertOccurance, result.Error
}
func MarkAlertOccuranceAsProcessed(id string, alertProcessType AlertType, date time.Time) error {
tx := DB.Debug().Model(&AlertOccurance{}).Where("id= ?", id).
Update("alert_process_type", alertProcessType).
Update("process_date", date)
return tx.Error
}
func UpdateSettings(setting *Setting) error { func UpdateSettings(setting *Setting) error {
tx := DB.Save(&setting) tx := DB.Save(&setting)

View File

@@ -36,6 +36,21 @@ const (
USER USER
) )
type AlertFrequency int
const (
ONETIME AlertFrequency = iota
RECURRING
)
type AlertType int
const (
DISTANCE AlertType = iota
TIME
BOTH
)
type EnumDetail struct { type EnumDetail struct {
Short string `json:"short"` Short string `json:"short"`
Long string `json:"long"` Long string `json:"long"`

View File

@@ -18,6 +18,15 @@ var migrations = []localMigration{
Name: "2021_06_24_04_42_SetUserDisabledFalse", Name: "2021_06_24_04_42_SetUserDisabledFalse",
Query: "update users set is_disabled=0", Query: "update users set is_disabled=0",
}, },
{
Name: "2021_02_07_00_09_LowerCaseEmails",
Query: "update users set email=lower(email)",
},
{
Name: "2022_03_08_13_16_AddVIN",
Query: "ALTER TABLE vehicles ADD COLUMN vin text",
},
} }
func RunMigrations() { func RunMigrations() {

21
server/models/alert.go Normal file
View File

@@ -0,0 +1,21 @@
package models
import (
"time"
"github.com/akhilrex/hammond/db"
)
type CreateAlertModel struct {
Comments string `json:"comments"`
Title string `json:"title"`
StartDate time.Time `json:"date"`
StartOdoReading int `json:"startOdoReading"`
DistanceUnit *db.DistanceUnit `json:"distanceUnit"`
AlertFrequency *db.AlertFrequency `json:"alertFrequency"`
OdoFrequency int `json:"odoFrequency"`
DayFrequency int `json:"dayFrequency"`
AlertAllUsers bool `json:"alertAllUsers"`
IsActive bool `json:"isActive"`
AlertType *db.AlertType `json:"alertType"`
}

View File

@@ -17,6 +17,7 @@ type SubItemQuery struct {
type CreateVehicleRequest struct { type CreateVehicleRequest struct {
Nickname string `form:"nickname" json:"nickname" binding:"required"` Nickname string `form:"nickname" json:"nickname" binding:"required"`
Registration string `form:"registration" json:"registration" binding:"required"` Registration string `form:"registration" json:"registration" binding:"required"`
VIN string `form:"vin" json:"vin"`
Make string `form:"make" json:"make" binding:"required"` Make string `form:"make" json:"make" binding:"required"`
Model string `form:"model" json:"model" binding:"required"` Model string `form:"model" json:"model" binding:"required"`
YearOfManufacture int `form:"yearOfManufacture" json:"yearOfManufacture"` YearOfManufacture int `form:"yearOfManufacture" json:"yearOfManufacture"`

View File

@@ -0,0 +1,172 @@
package service
import (
"errors"
"time"
"github.com/akhilrex/hammond/db"
"github.com/akhilrex/hammond/models"
)
func CreateAlert(model models.CreateAlertModel, vehicleId, userId string) (*db.VehicleAlert, error) {
alert := db.VehicleAlert{
VehicleID: vehicleId,
UserID: userId,
Title: model.Title,
Comments: model.Comments,
StartDate: model.StartDate,
StartOdoReading: model.StartOdoReading,
DistanceUnit: *model.DistanceUnit,
AlertFrequency: *model.AlertFrequency,
OdoFrequency: model.OdoFrequency,
DayFrequency: model.DayFrequency,
AlertAllUsers: model.AlertAllUsers,
IsActive: model.IsActive,
AlertType: *model.AlertType,
}
tx := db.DB.Create(&alert)
if tx.Error != nil {
return nil, tx.Error
}
go CreateAlertInstance(alert.ID)
return &alert, nil
}
func CreateAlertInstance(alertId string) error {
alert, err := db.GeAlertById(alertId)
if err != nil {
return err
}
existingOccurence, err := db.GetAlertOccurenceByAlertId(alertId)
if err != nil {
return err
}
var lastOccurance db.AlertOccurance
useOccurance := false
if len(*existingOccurence) > 0 {
lastOccurance = (*existingOccurence)[0]
useOccurance = true
if alert.AlertFrequency == db.ONETIME {
return errors.New("Only single occurance is possible for this kind of alert")
}
}
users := []string{alert.UserID}
if alert.AlertAllUsers {
allUsers, err := db.GetVehicleUsers(alert.VehicleID)
if err != nil {
return err
}
users = make([]string, len(*allUsers))
for i, user := range *allUsers {
users[i] = user.UserID
}
}
for _, userId := range users {
model := db.AlertOccurance{
VehicleID: alert.VehicleID,
UserID: userId,
VehicleAlertID: alertId,
}
if alert.AlertType == db.DISTANCE || alert.AlertType == db.BOTH {
model.OdoReading = alert.StartOdoReading + alert.OdoFrequency
if useOccurance {
model.OdoReading = lastOccurance.OdoReading + alert.OdoFrequency
}
}
if alert.AlertType == db.TIME || alert.AlertType == db.BOTH {
date := alert.StartDate.Add(time.Duration(alert.DayFrequency) * 24 * time.Hour)
if useOccurance {
date = lastOccurance.Date.Add(time.Duration(alert.DayFrequency) * 24 * time.Hour)
}
model.Date = &date
}
tx := db.DB.Create(&model)
if tx.Error != nil {
return tx.Error
}
}
return nil
}
func ProcessAlertOccurance(occurance db.AlertOccurance, today time.Time) error {
if occurance.ProcessDate != nil {
return errors.New("Alert occurence already processed")
}
alert := occurance.VehicleAlert
if !alert.IsActive {
return errors.New("Alert is not active")
}
notification := db.Notification{
Title: alert.Title,
Content: alert.Comments,
UserID: occurance.UserID,
VehicleID: occurance.VehicleID,
Date: today,
ParentID: occurance.ID,
ParentType: "AlertOccurance",
}
var alertProcessType db.AlertType
if alert.AlertType == db.DISTANCE || alert.AlertType == db.BOTH {
odoReading, err := GetLatestOdoReadingForVehicle(occurance.VehicleID)
if err != nil {
return err
}
if odoReading >= occurance.OdoReading {
alertProcessType = db.DISTANCE
}
}
if alert.AlertType == db.TIME || alert.AlertType == db.BOTH {
if occurance.Date.Before(today) {
alertProcessType = db.TIME
}
}
db.DB.Create(&notification)
return db.MarkAlertOccuranceAsProcessed(occurance.ID, alertProcessType, today)
}
func FindAlertOccurancesToProcess(today time.Time) ([]db.AlertOccurance, error) {
occurances, err := db.GetUnprocessedAlertOccurances()
if err != nil {
return nil, err
}
if len(*occurances) == 0 {
return make([]db.AlertOccurance, 0), nil
}
var toReturn []db.AlertOccurance
for _, occurance := range *occurances {
alert := occurance.VehicleAlert
if !alert.IsActive {
continue
}
if alert.AlertType == db.DISTANCE || alert.AlertType == db.BOTH {
odoReading, err := GetLatestOdoReadingForVehicle(occurance.VehicleID)
if err != nil {
return nil, err
}
if odoReading >= occurance.OdoReading {
toReturn = append(toReturn, occurance)
continue
}
}
if alert.AlertType == db.TIME || alert.AlertType == db.BOTH {
if occurance.Date.Before(today) {
toReturn = append(toReturn, occurance)
continue
}
}
}
return toReturn, nil
}
func MarkAlertOccuranceAsCompleted() {
}

View File

@@ -1,6 +1,8 @@
package service package service
import ( import (
"strings"
"github.com/akhilrex/hammond/db" "github.com/akhilrex/hammond/db"
"github.com/akhilrex/hammond/models" "github.com/akhilrex/hammond/models"
) )
@@ -8,7 +10,7 @@ import (
func CreateUser(userModel *models.RegisterRequest, role db.Role) error { func CreateUser(userModel *models.RegisterRequest, role db.Role) error {
setting := db.GetOrCreateSetting() setting := db.GetOrCreateSetting()
toCreate := db.User{ toCreate := db.User{
Email: userModel.Email, Email: strings.ToLower(userModel.Email),
Name: userModel.Name, Name: userModel.Name,
Role: role, Role: role,
Currency: setting.Currency, Currency: setting.Currency,

View File

@@ -5,6 +5,7 @@ import (
"github.com/akhilrex/hammond/db" "github.com/akhilrex/hammond/db"
"github.com/akhilrex/hammond/models" "github.com/akhilrex/hammond/models"
"gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@@ -13,6 +14,7 @@ func CreateVehicle(model models.CreateVehicleRequest, userId string) (*db.Vehicl
Nickname: model.Nickname, Nickname: model.Nickname,
Registration: model.Registration, Registration: model.Registration,
Model: model.Model, Model: model.Model,
VIN: model.VIN,
Make: model.Make, Make: model.Make,
YearOfManufacture: model.YearOfManufacture, YearOfManufacture: model.YearOfManufacture,
EngineSize: model.EngineSize, EngineSize: model.EngineSize,
@@ -99,6 +101,7 @@ func UpdateVehicle(vehicleID string, model models.UpdateVehicleRequest) error {
//return db.DB.Model(&toUpdate).Updates(db.Vehicle{ //return db.DB.Model(&toUpdate).Updates(db.Vehicle{
toUpdate.Nickname = model.Nickname toUpdate.Nickname = model.Nickname
toUpdate.Registration = model.Registration toUpdate.Registration = model.Registration
toUpdate.VIN = model.VIN
toUpdate.Model = model.Model toUpdate.Model = model.Model
toUpdate.Make = model.Make toUpdate.Make = model.Make
toUpdate.YearOfManufacture = model.YearOfManufacture toUpdate.YearOfManufacture = model.YearOfManufacture
@@ -243,6 +246,24 @@ func GetDistinctFuelSubtypesForVehicle(vehicleId string) ([]string, error) {
return names, tx.Error return names, tx.Error
} }
func GetLatestOdoReadingForVehicle(vehicleId string) (int, error) {
odoReading := 0
latestFillup, err := db.GetLatestExpenseByVehicleId(vehicleId)
if err != nil && err != gorm.ErrRecordNotFound {
return 0, err
}
odoReading = latestFillup.OdoReading
latestExpense, err := db.GetLatestExpenseByVehicleId(vehicleId)
if err != nil && err != gorm.ErrRecordNotFound {
return 0, err
}
if latestExpense.OdoReading > odoReading {
odoReading = latestExpense.OdoReading
}
return odoReading, nil
}
func GetUserStats(userId string, model models.UserStatsQueryModel) ([]models.VehicleStatsModel, error) { func GetUserStats(userId string, model models.UserStatsQueryModel) ([]models.VehicleStatsModel, error) {
vehicles, err := GetUserVehicles(userId) vehicles, err := GetUserVehicles(userId)

1449
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,13 +34,13 @@
"@fortawesome/fontawesome-svg-core": "^1.2.27", "@fortawesome/fontawesome-svg-core": "^1.2.27",
"@fortawesome/free-solid-svg-icons": "^5.12.1", "@fortawesome/free-solid-svg-icons": "^5.12.1",
"@fortawesome/vue-fontawesome": "0.1.9", "@fortawesome/vue-fontawesome": "0.1.9",
"axios": "0.19.2", "axios": "^0.27.0",
"buefy": "^0.9.7", "buefy": "^0.9.7",
"chart.js": "^2.9.4", "chart.js": "^2.9.4",
"core-js": "3.6.4", "core-js": "3.6.4",
"currency-formatter": "^1.5.7", "currency-formatter": "^1.5.7",
"date-fns": "2.10.0", "date-fns": "2.10.0",
"lodash": "4.17.15", "lodash": "^4.17.21",
"normalize.css": "8.0.1", "normalize.css": "8.0.1",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"vue": "2.6.11", "vue": "2.6.11",
@@ -70,7 +70,7 @@
"hygen": "4.0.x", "hygen": "4.0.x",
"imagemin-lint-staged": "0.4.x", "imagemin-lint-staged": "0.4.x",
"lint-staged": "10.0.x", "lint-staged": "10.0.x",
"markdownlint-cli": "0.22.x", "markdownlint-cli": "^0.31.1",
"npm-run-all": "4.1.x", "npm-run-all": "4.1.x",
"sass": "1.26.x", "sass": "1.26.x",
"sass-loader": "8.0.x", "sass-loader": "8.0.x",

View File

@@ -106,6 +106,7 @@ export default {
if (currentDayOfWeek > 1) { if (currentDayOfWeek > 1) {
toSubtract = -1 * (currentDayOfWeek - 1) toSubtract = -1 * (currentDayOfWeek - 1)
} }
toDate.setHours(0, 0, 0, 0)
return addDays(toDate, toSubtract) return addDays(toDate, toSubtract)
case 'this_month': case 'this_month':
return new Date(toDate.getFullYear(), toDate.getMonth(), 1) return new Date(toDate.getFullYear(), toDate.getMonth(), 1)
@@ -114,7 +115,7 @@ export default {
case 'past_3_months': case 'past_3_months':
return addMonths(toDate, -3) return addMonths(toDate, -3)
case 'this_year': case 'this_year':
return new Date(toDate.getFullYear(), 1, 1) return new Date(toDate.getFullYear(), 0, 1)
case 'all_time': case 'all_time':
return new Date(1969, 4, 20) return new Date(1969, 4, 20)
default: default:

View File

@@ -47,6 +47,7 @@ export default {
fuelUnit: null, fuelUnit: null,
fuelType: null, fuelType: null,
registration: '', registration: '',
vin: '',
nickname: '', nickname: '',
engineSize: null, engineSize: null,
make: '', make: '',
@@ -58,6 +59,7 @@ export default {
fuelUnit: veh.fuelUnit, fuelUnit: veh.fuelUnit,
fuelType: veh.fuelType, fuelType: veh.fuelType,
registration: veh.registration, registration: veh.registration,
vin: veh.vin,
nickname: veh.nickname, nickname: veh.nickname,
engineSize: veh.engineSize, engineSize: veh.engineSize,
make: veh.make, make: veh.make,
@@ -138,6 +140,9 @@ export default {
<b-field label="Registration*"> <b-field label="Registration*">
<b-input v-model="vehicleModel.registration" type="text" expanded required></b-input> <b-input v-model="vehicleModel.registration" type="text" expanded required></b-input>
</b-field> </b-field>
<b-field label="VIN">
<b-input v-model="vehicleModel.vin" type="text" expanded></b-input>
</b-field>
<b-field label="Fuel Type*"> <b-field label="Fuel Type*">
<b-select v-model.number="vehicleModel.fuelType" placeholder="Fuel Type" required expanded> <b-select v-model.number="vehicleModel.fuelType" placeholder="Fuel Type" required expanded>
<option v-for="(option, key) in fuelTypeMasters" :key="key" :value="key"> <option v-for="(option, key) in fuelTypeMasters" :key="key" :value="key">

View File

@@ -21,13 +21,27 @@ export default {
email: '', email: '',
password: '', password: '',
distanceUnit: 1, distanceUnit: 1,
currency: 'INR', currency: '',
}, },
} }
}, },
computed: { computed: {
...mapGetters('auth', ['isInitialized']), ...mapGetters('auth', ['isInitialized']),
...mapState('vehicles', ['currencyMasters', 'distanceUnitMasters']), ...mapState('vehicles', ['currencyMasters', 'distanceUnitMasters']),
filteredCurrencyMasters() {
return this.currencyMasters.filter((option) => {
return (
option.namePlural
.toString()
.toLowerCase()
.indexOf(this.registerModel.currency.toLowerCase()) >= 0 ||
option.code
.toString()
.toLowerCase()
.indexOf(this.registerModel.currency.toLowerCase()) >= 0
)
})
},
}, },
mounted() { mounted() {
store.dispatch('vehicles/fetchMasters').then((data) => {}) store.dispatch('vehicles/fetchMasters').then((data) => {})
@@ -139,6 +153,9 @@ export default {
}) })
.finally(() => (this.isWorking = false)) .finally(() => (this.isWorking = false))
}, },
formatCurrency(option) {
return `${option.namePlural} (${option.code})`
},
}, },
} }
</script> </script>
@@ -148,15 +165,10 @@ export default {
<div v-if="!migrationMode" class="box"> <div v-if="!migrationMode" class="box">
<h1 class="title">Migrate from Clarkson</h1> <h1 class="title">Migrate from Clarkson</h1>
<p> <p>
If you have an existing Clarkson deployment and you want to migrate your data from that, If you have an existing Clarkson deployment and you want to migrate your data from that, press the following button.
press the following button.
</p> </p>
<br /> <br />
<b-field> <b-field> <b-button type="is-primary" @click="migrationMode = 'clarkson'">Migrate from Clarkson</b-button></b-field>
<b-button type="is-primary" @click="migrationMode = 'clarkson'"
>Migrate from Clarkson</b-button
></b-field
>
</div> </div>
<div v-if="!migrationMode" class="box"> <div v-if="!migrationMode" class="box">
<h1 class="title">Fresh Install</h1> <h1 class="title">Fresh Install</h1>
@@ -170,21 +182,12 @@ export default {
</div> </div>
<div v-if="migrationMode === 'clarkson'" class="box content"> <div v-if="migrationMode === 'clarkson'" class="box content">
<h1 class="title">Migrate from Clarkson</h1> <h1 class="title">Migrate from Clarkson</h1>
<p>You need to make sure that this deployment of Hammond can access the MySQL database used by Clarkson.</p>
<p>If that is not directly possible, you can make a copy of that database somewhere accessible from this instance.</p>
<p>Once that is done, enter the connection string to the MySQL instance in the following format.</p>
<p <p
>You need to make sure that this deployment of Hammond can access the MySQL database used by >All the users imported from Clarkson will have their username as their email in Clarkson database and pasword set to
Clarkson.</p <span class="" style="font-weight:bold">hammond</span></p
>
<p
>If that is not directly possible, you can make a copy of that database somewhere accessible
from this instance.</p
>
<p
>Once that is done, enter the connection string to the MySQL instance in the following
format.</p
>
<p
>All the users imported from Clarkson will have their username as their email in Clarkson
database and pasword set to <span class="" style="font-weight:bold">hammond</span></p
> >
<code> <code>
user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
@@ -200,15 +203,8 @@ export default {
</b-field> </b-field>
<div class="buttons"> <div class="buttons">
<b-button <b-button v-if="!testSuccess" type="is-primary" :disabled="isWorking" @click="testConnection">Test Connection</b-button
v-if="!testSuccess" ><b-button v-if="testSuccess" type="is-success" :disabled="isWorking" @click="migrate">Migrate</b-button>
type="is-primary"
:disabled="isWorking"
@click="testConnection"
>Test Connection</b-button
><b-button v-if="testSuccess" type="is-success" :disabled="isWorking" @click="migrate"
>Migrate</b-button
>
<b-button type="is-danger is-light" @click="resetMigrationMode">Cancel</b-button> <b-button type="is-danger is-light" @click="resetMigrationMode">Cancel</b-button>
</div> </div>
</div> </div>
@@ -222,28 +218,22 @@ export default {
<b-input v-model="registerModel.email" type="email" required></b-input> <b-input v-model="registerModel.email" type="email" required></b-input>
</b-field> </b-field>
<b-field label="Your Password"> <b-field label="Your Password">
<b-input <b-input v-model="registerModel.password" type="password" required minlength="8" password-reveal></b-input>
v-model="registerModel.password"
type="password"
required
minlength="8"
password-reveal
></b-input>
</b-field> </b-field>
<b-field label="Currency"> <b-field label="Currency">
<b-select v-model="registerModel.currency" placeholder="Currency" required expanded> <b-autocomplete
<option v-for="option in currencyMasters" :key="option.code" :value="option.code"> v-model="registerModel.currency"
{{ `${option.namePlural} (${option.code})` }} :custom-formatter="formatCurrency"
</option> placeholder="Currency"
</b-select> :data="filteredCurrencyMasters"
:keep-first="true"
:open-on-focus="true"
required
@select="(option) => (selected = option)"
></b-autocomplete>
</b-field> </b-field>
<b-field label="Distance Unit"> <b-field label="Distance Unit">
<b-select <b-select v-model.number="registerModel.distanceUnit" placeholder="Distance Unit" required expanded>
v-model.number="registerModel.distanceUnit"
placeholder="Distance Unit"
required
expanded
>
<option v-for="(option, key) in distanceUnitMasters" :key="key" :value="key"> <option v-for="(option, key) in distanceUnitMasters" :key="key" :value="key">
{{ `${option.long} (${option.short})` }} {{ `${option.long} (${option.short})` }}
</option> </option>

View File

@@ -16,7 +16,7 @@ export default {
password: '', password: '',
authError: null, authError: null,
tryingToLogIn: false, tryingToLogIn: false,
errorMessage:'' errorMessage: '',
} }
}, },
computed: { computed: {
@@ -38,7 +38,7 @@ export default {
// and password they provided. // and password they provided.
tryToLogIn() { tryToLogIn() {
this.tryingToLogIn = true this.tryingToLogIn = true
this.errorMessage=''; this.errorMessage = ''
// Reset the authError if it existed. // Reset the authError if it existed.
this.authError = null this.authError = null
return this.logIn({ return this.logIn({
@@ -53,9 +53,9 @@ export default {
// Redirect to the originally requested page, or to the home page // Redirect to the originally requested page, or to the home page
}) })
.catch((error) => { .catch((error) => {
if(error.response.data?.errors?.login){ if (error.response.data?.errors?.login) {
this.errorMessage=error.response.data.errors.login this.errorMessage = error.response.data.errors.login
} }
this.tryingToLogIn = false this.tryingToLogIn = false
this.authError = error this.authError = error
}) })
@@ -67,21 +67,9 @@ export default {
<template> <template>
<Layout> <Layout>
<form @submit.prevent="tryToLogIn"> <form @submit.prevent="tryToLogIn">
<b-field label="Email"> <b-field label="Email"> <b-input v-model="username" tag="b-input" name="username" type="email" :placeholder="placeholders.username"/></b-field>
<b-input
v-model="username"
tag="b-input"
name="username"
:placeholder="placeholders.username"
/></b-field>
<b-field label="Password"> <b-field label="Password">
<b-input <b-input v-model="password" tag="b-input" name="password" type="password" :placeholder="placeholders.password" />
v-model="password"
tag="b-input"
name="password"
type="password"
:placeholder="placeholders.password"
/>
</b-field> </b-field>
<b-button tag="input" native-type="submit" :disabled="tryingToLogIn" type="is-primary"> <b-button tag="input" native-type="submit" :disabled="tryingToLogIn" type="is-primary">
<BaseIcon v-if="tryingToLogIn" name="sync" spin /> <BaseIcon v-if="tryingToLogIn" name="sync" spin />
@@ -89,9 +77,7 @@ export default {
Log in Log in
</span> </span>
</b-button> </b-button>
<p v-if="authError"> <p v-if="authError"> There was an error logging in to your account. {{ errorMessage }} </p>
There was an error logging in to your account. {{errorMessage}}
</p>
</form> </form>
</Layout> </Layout>
</template> </template>

View File

@@ -44,6 +44,20 @@ export default {
return this.changePassModel.new === this.changePassModel.renew return this.changePassModel.new === this.changePassModel.renew
}, },
filteredCurrencyMasters() {
return this.currencyMasters.filter((option) => {
return (
option.namePlural
.toString()
.toLowerCase()
.indexOf(this.settingsModel.currency.toLowerCase()) >= 0 ||
option.code
.toString()
.toLowerCase()
.indexOf(this.settingsModel.currency.toLowerCase()) >= 0
)
})
},
}, },
methods: { methods: {
changePassword() { changePassword() {
@@ -109,6 +123,9 @@ export default {
this.tryingToSave = false this.tryingToSave = false
}) })
}, },
formatCurrency(option) {
return `${option.namePlural} (${option.code})`
},
}, },
} }
</script> </script>
@@ -123,11 +140,16 @@ export default {
These will be used as default values whenever you create a new fillup or expense. These will be used as default values whenever you create a new fillup or expense.
</h1> </h1>
<b-field label="Currency"> <b-field label="Currency">
<b-select v-model="settingsModel.currency" placeholder="Currency" required expanded> <b-autocomplete
<option v-for="option in currencyMasters" :key="option.code" :value="option.code"> v-model="settingsModel.currency"
{{ `${option.namePlural} (${option.code})` }} :custom-formatter="formatCurrency"
</option> placeholder="Currency"
</b-select> :data="filteredCurrencyMasters"
:keep-first="true"
:open-on-focus="true"
required
@select="(option) => (selected = option)"
></b-autocomplete>
</b-field> </b-field>
<b-field label="Distance Unit"> <b-field label="Distance Unit">
<b-select v-model.number="settingsModel.distanceUnit" placeholder="Distance Unit" required expanded> <b-select v-model.number="settingsModel.distanceUnit" placeholder="Distance Unit" required expanded>
@@ -181,7 +203,7 @@ export default {
<table class="table is-hoverable"> <table class="table is-hoverable">
<tr> <tr>
<td>Current Version</td> <td>Current Version</td>
<td>2021.09.20</td> <td>2022.07.06</td>
</tr> </tr>
<tr> <tr>
<td>Website</td> <td>Website</td>

View File

@@ -199,14 +199,21 @@ export default {
return return
} }
this.tryingToUpload = true this.tryingToUpload = true
const formData = new FormData() const formData = new FormData()
formData.append('file', this.file, this.file.name) formData.append('file', this.file, this.file.name)
formData.append('title', this.title) formData.append('title', this.title)
axios // const config = { headers: { 'Content-Type': 'multipart/form-data; boundary=' + formData._boundary } }
.post(`/api/vehicles/${this.vehicle.id}/attachments`, formData) fetch(`/api/vehicles/${this.vehicle.id}/attachments`, {
method: 'POST',
body: formData,
headers: {
Authorization: this.currentUser.token,
},
})
.then((data) => { .then((data) => {
this.$buefy.toast.open({ this.$buefy.toast.open({
message: 'Quick Entry Created Successfully', message: 'File uploaded Successfully',
type: 'is-success', type: 'is-success',
duration: 3000, duration: 3000,
}) })