Compare commits

...

37 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
0b715ef840 Fix warnings generated by go-staticcheck 2022-04-28 17:52:57 +02: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
Akhil Gupta
24105dbaaf Merge pull request #59 from meichthys/patch-1
remove whitespace from JWT_SECRET
2022-04-25 16:42:53 +05:30
Akhil Gupta
e3846634b5 Merge pull request #75 from AlfHou/icon-mobile
Add icon for mobile homescreens
2022-04-25 16:42:25 +05:30
Akhil Gupta
fd52c23636 Merge pull request #77 from AlfHou/mileage_on_odometer 2022-04-25 16:41:11 +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
20a1421576 Calculate mileage on odometer order instead of time 2022-03-15 14:26:46 +01:00
Alf Sebastian Houge
8410674841 Add vin field vehicle data 2022-03-08 13:49:11 +01:00
Alf Sebastian Houge
74e52c3e87 Change favicon to match mobile icon 2022-03-07 16:53:47 +01:00
Alf Sebastian Houge
1857bb0518 Change icon to gas pump icon instead of text 2022-03-07 10:41:28 +01:00
Alf Sebastian Houge
a729b5eb12 Add icon for mobile homescreens 2022-03-07 00:46:42 +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
MeIchthys
04f45fe385 remove whitespace from JWT_SECRET
When deploying with the whitespace around the `=`, docker complains.
2022-01-20 23:55:39 -05:00
Akhil Gupta
fca2c3e7fa start alerts 2021-09-03 15:46:39 +05:30
33 changed files with 1593 additions and 526 deletions

View File

@@ -1,45 +1,46 @@
name: ci
name: Build docker image
on:
push:
branches: master
release:
types: [published]
jobs:
multi:
runs-on: ubuntu-latest
steps:
-
name: Checkout
- name: Checkout
uses: actions/checkout@v2
-
name: Set up QEMU
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v1
-
name: Set up Docker Buildx
with:
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
-
name: Set up build cache
- name: Set up build cache
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
-
name: Login to DockerHub
uses: docker/login-action@v1
- name: Parse the git tag
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
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Login to GitHub
- name: Login to GitHub
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.CR_PAT }}
-
name: Build and push
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
@@ -48,10 +49,10 @@ jobs:
#platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
# cache-from: type=local,src=/tmp/.buildx-cache
# cache-to: type=local,dest=/tmp/.buildx-cache
tags: |
akhilrex/hammond:latest
akhilrex/hammond:1.0.0
akhilrex/hammond:${{ steps.get_tag.outputs.TAG }}
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 .
RUN go build -o ./app ./main.go
FROM node:latest as build-stage
FROM node:14 as build-stage
WORKDIR /app
COPY ./ui/package*.json ./
RUN npm install
@@ -36,4 +36,4 @@ COPY --from=builder /api/app .
#COPY dist ./dist
COPY --from=build-stage /app/dist ./dist
EXPOSE 3000
ENTRYPOINT ["./app"]
ENTRYPOINT ["./app"]

View File

@@ -8,7 +8,7 @@
</a> -->
<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">
A self-hosted vehicle expense tracking system with support for multiple users.
@@ -35,6 +35,7 @@
- [Built With](#built-with)
- [Features](#features)
- [Installation](#installation)
- [Contributing](#contributing)
- [License](#license)
- [Roadmap](#roadmap)
- [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.
## 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
Distributed under the GPL-3.0 License. See `LICENSE` for more information.

View File

@@ -4,7 +4,7 @@ services:
image: akhilrex/hammond
container_name: hammond
environment:
- JWT_SECRET = somethingverystrong
- JWT_SECRET=somethingverystrong
volumes:
- /path/to/config:/config
- /path/to/data:/assets

4
server/.gitignore vendored
View File

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

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"os"
"strings"
"github.com/akhilrex/hammond/common"
"github.com/akhilrex/hammond/db"
@@ -91,20 +92,20 @@ func userLogin(c *gin.Context) {
c.JSON(http.StatusUnprocessableEntity, common.NewValidatorError(err))
return
}
user, err := db.FindOneUser(&db.User{Email: loginRequest.Email})
user, err := db.FindOneUser(&db.User{Email: strings.ToLower(loginRequest.Email)})
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")))
return
}
if user.CheckPassword(loginRequest.Password) != 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")))
return
}
if user.IsDisabled {
c.JSON(http.StatusForbidden, common.NewError("login", errors.New("Your user has been disabled by the admin. Please contact them to get it re-enabled.")))
c.JSON(http.StatusForbidden, common.NewError("login", errors.New("your user has been disabled by the admin. Please contact them to get it re-enabled")))
return
}
UpdateContextUserModel(c, user.ID)
@@ -170,16 +171,16 @@ func changePassword(c *gin.Context) {
user, err := service.GetUserById(c.GetString("userId"))
if err != nil {
c.JSON(http.StatusForbidden, common.NewError("changePassword", errors.New("Not Registered email or invalid password")))
c.JSON(http.StatusForbidden, common.NewError("changePassword", errors.New("not Registered email or invalid password")))
return
}
if user.CheckPassword(request.OldPassword) != nil {
c.JSON(http.StatusForbidden, common.NewError("changePassword", errors.New("Incorrect old password")))
c.JSON(http.StatusForbidden, common.NewError("changePassword", errors.New("incorrect old password")))
return
}
user.SetPassword(request.NewPassword)
success, err := service.UpdatePassword(user.ID, request.NewPassword)
success, _ := service.UpdatePassword(user.ID, request.NewPassword)
c.JSON(http.StatusOK, success)
}

View File

@@ -23,8 +23,8 @@ func stripBearerPrefixFromTokenString(tok string) (string, error) {
// Extract token from Authorization header
// Uses PostExtractionFilter to strip "TOKEN " prefix from header
var AuthorizationHeaderExtractor = &request.PostExtractionFilter{
request.HeaderExtractor{"Authorization"},
stripBearerPrefixFromTokenString,
Extractor: request.HeaderExtractor{"Authorization"},
Filter: stripBearerPrefixFromTokenString,
}
// Extractor for OAuth2 access tokens. Looks in 'Authorization'

View File

@@ -51,7 +51,7 @@ func migrate(c *gin.Context) {
canMigrate, _, _ := db.CanMigrate(request.Url)
if !canMigrate {
c.JSON(http.StatusBadRequest, fmt.Errorf("cannot migrate database. please check connection string."))
c.JSON(http.StatusBadRequest, fmt.Errorf("cannot migrate database. please check connection string"))
return
}

View File

@@ -397,7 +397,7 @@ func deleteVehicle(c *gin.Context) {
return
}
if !canDelete {
c.JSON(http.StatusUnprocessableEntity, common.NewError("shareVehicle", errors.New("You are not allowed to delete this vehicle.")))
c.JSON(http.StatusUnprocessableEntity, common.NewError("shareVehicle", errors.New("you are not allowed to delete this vehicle")))
return
}
err = service.DeleteVehicle(searchByIdQuery.Id)

View File

@@ -60,6 +60,7 @@ type Vehicle struct {
Base
Nickname string `json:"nickname"`
Registration string `json:"registration"`
VIN string `json:"vin"`
Make string `json:"make"`
Model string `json:"model"`
YearOfManufacture int `json:"yearOfManufacture"`
@@ -195,3 +196,50 @@ type VehicleAttachment struct {
VehicleID string `gorm:"primaryKey" json:"vehicleId"`
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

@@ -117,7 +117,7 @@ func UnshareVehicle(vehicleId, userId string) error {
return nil
}
if mapping.IsOwner {
return fmt.Errorf("Cannot unshare owner")
return fmt.Errorf("cannot unshare owner")
}
result := DB.Where("id=?", mapping.ID).Delete(&UserVehicle{})
return result.Error
@@ -160,6 +160,11 @@ func GetFillupsByVehicleId(id string) (*[]Fillup, error) {
result := DB.Preload(clause.Associations).Order("date desc").Find(&obj, &Fillup{VehicleID: id})
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) {
var obj []Fillup
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})
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) {
var obj Expense
result := DB.Preload(clause.Associations).First(&obj, "id=?", id)
@@ -271,6 +281,29 @@ func GetVehicleAttachments(vehicleId string) (*[]Attachment, error) {
}
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 {
tx := DB.Save(&setting)
@@ -332,8 +365,7 @@ func UnlockMissedJobs() {
if (job.Date == time.Time{}) {
continue
}
var duration time.Duration
duration = time.Duration(job.Duration)
var duration = time.Duration(job.Duration)
d := job.Date.Add(time.Minute * duration)
if d.Before(time.Now()) {
fmt.Println(job.Name + " is unlocked")

View File

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

View File

@@ -18,6 +18,15 @@ var migrations = []localMigration{
Name: "2021_06_24_04_42_SetUserDisabledFalse",
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() {

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 {
Nickname string `form:"nickname" json:"nickname" binding:"required"`
Registration string `form:"registration" json:"registration" binding:"required"`
VIN string `form:"vin" json:"vin"`
Make string `form:"make" json:"make" binding:"required"`
Model string `form:"model" json:"model" binding:"required"`
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

@@ -3,7 +3,6 @@ package service
import (
"archive/tar"
"compress/gzip"
"errors"
"fmt"
"io"
"net/http"
@@ -126,14 +125,14 @@ func CreateBackup() (string, error) {
tarballFilePath := path.Join(folder, backupFileName)
file, err := os.Create(tarballFilePath)
if err != nil {
return "", errors.New(fmt.Sprintf("Could not create tarball file '%s', got error '%s'", tarballFilePath, err.Error()))
return "", fmt.Errorf("could not create tarball file '%s', got error '%s'", tarballFilePath, err.Error())
}
defer file.Close()
dbPath := path.Join(configPath, "hammond.db")
_, err = os.Stat(dbPath)
if err != nil {
return "", errors.New(fmt.Sprintf("Could not find db file '%s', got error '%s'", dbPath, err.Error()))
return "", fmt.Errorf("could not find db file '%s', got error '%s'", dbPath, err.Error())
}
gzipWriter := gzip.NewWriter(file)
defer gzipWriter.Close()
@@ -151,13 +150,13 @@ func CreateBackup() (string, error) {
func addFileToTarWriter(filePath string, tarWriter *tar.Writer) error {
file, err := os.Open(filePath)
if err != nil {
return errors.New(fmt.Sprintf("Could not open file '%s', got error '%s'", filePath, err.Error()))
return fmt.Errorf("could not open file '%s', got error '%s'", filePath, err.Error())
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return errors.New(fmt.Sprintf("Could not get stat for file '%s', got error '%s'", filePath, err.Error()))
return fmt.Errorf("could not get stat for file '%s', got error '%s'", filePath, err.Error())
}
header := &tar.Header{
@@ -169,12 +168,12 @@ func addFileToTarWriter(filePath string, tarWriter *tar.Writer) error {
err = tarWriter.WriteHeader(header)
if err != nil {
return errors.New(fmt.Sprintf("Could not write header for file '%s', got error '%s'", filePath, err.Error()))
return fmt.Errorf("could not write header for file '%s', got error '%s'", filePath, err.Error())
}
_, err = io.Copy(tarWriter, file)
if err != nil {
return errors.New(fmt.Sprintf("Could not copy the file '%s' data to the tarball, got error '%s'", filePath, err.Error()))
return fmt.Errorf("could not copy the file '%s' data to the tarball, got error '%s'", filePath, err.Error())
}
return nil

View File

@@ -1,6 +1,7 @@
package service
import (
"sort"
"time"
"github.com/akhilrex/hammond/db"
@@ -15,6 +16,9 @@ func GetMileageByVehicleId(vehicleId string, since time.Time) (mileage []models.
fillups := make([]db.Fillup, len(*data))
copy(fillups, *data)
sort.Slice(fillups, func(i, j int) bool {
return fillups[i].OdoReading > fillups[j].OdoReading
})
var mileages []models.MileageModel

View File

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

View File

@@ -5,6 +5,7 @@ import (
"github.com/akhilrex/hammond/db"
"github.com/akhilrex/hammond/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -13,6 +14,7 @@ func CreateVehicle(model models.CreateVehicleRequest, userId string) (*db.Vehicl
Nickname: model.Nickname,
Registration: model.Registration,
Model: model.Model,
VIN: model.VIN,
Make: model.Make,
YearOfManufacture: model.YearOfManufacture,
EngineSize: model.EngineSize,
@@ -99,6 +101,7 @@ func UpdateVehicle(vehicleID string, model models.UpdateVehicleRequest) error {
//return db.DB.Model(&toUpdate).Updates(db.Vehicle{
toUpdate.Nickname = model.Nickname
toUpdate.Registration = model.Registration
toUpdate.VIN = model.VIN
toUpdate.Model = model.Model
toUpdate.Make = model.Make
toUpdate.YearOfManufacture = model.YearOfManufacture
@@ -243,6 +246,24 @@ func GetDistinctFuelSubtypesForVehicle(vehicleId string) ([]string, 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) {
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/free-solid-svg-icons": "^5.12.1",
"@fortawesome/vue-fontawesome": "0.1.9",
"axios": "0.19.2",
"axios": "^0.27.0",
"buefy": "^0.9.7",
"chart.js": "^2.9.4",
"core-js": "3.6.4",
"currency-formatter": "^1.5.7",
"date-fns": "2.10.0",
"lodash": "4.17.15",
"lodash": "^4.17.21",
"normalize.css": "8.0.1",
"nprogress": "0.2.0",
"vue": "2.6.11",
@@ -70,7 +70,7 @@
"hygen": "4.0.x",
"imagemin-lint-staged": "0.4.x",
"lint-staged": "10.0.x",
"markdownlint-cli": "0.22.x",
"markdownlint-cli": "^0.31.1",
"npm-run-all": "4.1.x",
"sass": "1.26.x",
"sass-loader": "8.0.x",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 B

After

Width:  |  Height:  |  Size: 895 B

View File

@@ -5,6 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="shortcut icon" href="<%= webpackConfig.output.publicPath %>hammond.png" />
<link rel="apple-touch-icon" href="<%= webpackConfig.output.publicPath %>touch-icon.png" />
<title><%= webpackConfig.name %></title>
</head>
<body>

BIN
ui/public/touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

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

View File

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

View File

@@ -21,13 +21,27 @@ export default {
email: '',
password: '',
distanceUnit: 1,
currency: 'INR',
currency: '',
},
}
},
computed: {
...mapGetters('auth', ['isInitialized']),
...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() {
store.dispatch('vehicles/fetchMasters').then((data) => {})
@@ -139,6 +153,9 @@ export default {
})
.finally(() => (this.isWorking = false))
},
formatCurrency(option) {
return `${option.namePlural} (${option.code})`
},
},
}
</script>
@@ -148,15 +165,10 @@ export default {
<div v-if="!migrationMode" class="box">
<h1 class="title">Migrate from Clarkson</h1>
<p>
If you have an existing Clarkson deployment and you want to migrate your data from that,
press the following button.
If you have an existing Clarkson deployment and you want to migrate your data from that, press the following button.
</p>
<br />
<b-field>
<b-button type="is-primary" @click="migrationMode = 'clarkson'"
>Migrate from Clarkson</b-button
></b-field
>
<b-field> <b-button type="is-primary" @click="migrationMode = 'clarkson'">Migrate from Clarkson</b-button></b-field>
</div>
<div v-if="!migrationMode" class="box">
<h1 class="title">Fresh Install</h1>
@@ -170,21 +182,12 @@ export default {
</div>
<div v-if="migrationMode === 'clarkson'" class="box content">
<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
>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
>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
>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>
user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
@@ -200,15 +203,8 @@ export default {
</b-field>
<div class="buttons">
<b-button
v-if="!testSuccess"
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 v-if="!testSuccess" 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>
</div>
</div>
@@ -222,28 +218,22 @@ export default {
<b-input v-model="registerModel.email" type="email" required></b-input>
</b-field>
<b-field label="Your Password">
<b-input
v-model="registerModel.password"
type="password"
required
minlength="8"
password-reveal
></b-input>
<b-input v-model="registerModel.password" type="password" required minlength="8" password-reveal></b-input>
</b-field>
<b-field label="Currency">
<b-select v-model="registerModel.currency" placeholder="Currency" required expanded>
<option v-for="option in currencyMasters" :key="option.code" :value="option.code">
{{ `${option.namePlural} (${option.code})` }}
</option>
</b-select>
<b-autocomplete
v-model="registerModel.currency"
:custom-formatter="formatCurrency"
placeholder="Currency"
:data="filteredCurrencyMasters"
:keep-first="true"
:open-on-focus="true"
required
@select="(option) => (selected = option)"
></b-autocomplete>
</b-field>
<b-field label="Distance Unit">
<b-select
v-model.number="registerModel.distanceUnit"
placeholder="Distance Unit"
required
expanded
>
<b-select v-model.number="registerModel.distanceUnit" placeholder="Distance Unit" required expanded>
<option v-for="(option, key) in distanceUnitMasters" :key="key" :value="key">
{{ `${option.long} (${option.short})` }}
</option>

View File

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

View File

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

View File

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