mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Merge branch '184-fix-time-zone'
# Conflicts: # views/settings.tpl.html
This commit is contained in:
commit
8231d76200
@ -14,9 +14,6 @@ import (
|
|||||||
"github.com/jinzhu/configor"
|
"github.com/jinzhu/configor"
|
||||||
"github.com/muety/wakapi/data"
|
"github.com/muety/wakapi/data"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"gorm.io/driver/mysql"
|
|
||||||
"gorm.io/driver/postgres"
|
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -203,56 +200,6 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *dbConfig) GetDialector() gorm.Dialector {
|
|
||||||
switch c.Dialect {
|
|
||||||
case SQLDialectMysql:
|
|
||||||
return mysql.New(mysql.Config{
|
|
||||||
DriverName: c.Dialect,
|
|
||||||
DSN: mysqlConnectionString(c),
|
|
||||||
})
|
|
||||||
case SQLDialectPostgres:
|
|
||||||
return postgres.New(postgres.Config{
|
|
||||||
DSN: postgresConnectionString(c),
|
|
||||||
})
|
|
||||||
case SQLDialectSqlite:
|
|
||||||
return sqlite.Open(sqliteConnectionString(c))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mysqlConnectionString(config *dbConfig) string {
|
|
||||||
//location, _ := time.LoadLocation("Local")
|
|
||||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
|
||||||
config.User,
|
|
||||||
config.Password,
|
|
||||||
config.Host,
|
|
||||||
config.Port,
|
|
||||||
config.Name,
|
|
||||||
config.Charset,
|
|
||||||
"Local",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func postgresConnectionString(config *dbConfig) string {
|
|
||||||
sslmode := "disable"
|
|
||||||
if config.Ssl {
|
|
||||||
sslmode = "require"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=%s",
|
|
||||||
config.Host,
|
|
||||||
config.Port,
|
|
||||||
config.User,
|
|
||||||
config.Name,
|
|
||||||
config.Password,
|
|
||||||
sslmode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sqliteConnectionString(config *dbConfig) string {
|
|
||||||
return config.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *appConfig) GetCustomLanguages() map[string]string {
|
func (c *appConfig) GetCustomLanguages() map[string]string {
|
||||||
return cloneStringMap(c.CustomLanguages, false)
|
return cloneStringMap(c.CustomLanguages, false)
|
||||||
}
|
}
|
||||||
|
85
config/db.go
Normal file
85
config/db.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
A quick note to myself including some clarifications about time zones.
|
||||||
|
|
||||||
|
- There are basically four time zones (at least in case of MySQL):
|
||||||
|
- User
|
||||||
|
- Wakapi (host system)
|
||||||
|
- MySQL server
|
||||||
|
- MySQL session
|
||||||
|
- From my understanding, MySQL server tz is only a fallback and can be ignored as long as a connection tz is specified
|
||||||
|
- All times are currently stored inside TIMESTAMP columns (alternatives would be DATETIME and BIGINT (plain Unix timestamps)
|
||||||
|
- TIMESTAMP columns, to my understanding, do not keep any time zone information, but only the very time they store
|
||||||
|
- Setting a session tz will still not cause any conversions to inserted TIMESTAMP, it will only make a difference when running functions like NOW() / CURRENT_TIMESTAMP()
|
||||||
|
- Query to insert a heartbeat involves, e.g., a `time` value like '2006-01-02 15:04:05-07:00', which doesn't contain time zone information is just saved as is
|
||||||
|
- However, as long as the Wakapi server always runs in the same time zone, it will always parse these dates the same way (i.e. as time.Local, Europe/Berlin in case of Wakapi.dev)
|
||||||
|
- Using TIMESTAMP columns would only become problematic when either data needs to be migrated to a Wakapi instance in a different tz or if two consumers in different tzs were reading and writing to the same table
|
||||||
|
- Wakapi always uses time.Local for everything, i.e. all times in the database have to be interpreted with that tz
|
||||||
|
- New heartbeats are sent with Python-like Unix timestamps, i.e. are absolute points in time as therefore not subject to any kind of tz issues
|
||||||
|
- E.g. with Wakapi running in Europe/Berlin, 1619379014.7335322 (2021-04-25T19:30:14.733Z (UTC)) will be inserted as 2021-04-25T21:30:14.733+0200 (CEST), but obviously represents the exact same point in time no matter where it originated from
|
||||||
|
- The reason why we need to explicitly care about tzs in the first place is the fact that user's can request their data within intervals and the results should correspond to their tz
|
||||||
|
- Users from California wouldn't have to care about their heartbeats being stored in German time zone
|
||||||
|
- However, they DO care when requesting their summaries
|
||||||
|
- A request with `?from=2021-04-25` from California (PST / UTC-7) would ideally have to be translated into a database query like `from >= 2021-04-25T00:00:00+0900)`, assuming that Wakapi runs at CEST (UTC+2)
|
||||||
|
- This translation comes from either the user explicitly requesting with a specified tz (i.e. sending `from` as ISO8601 / RFC3999) or them having specified a tz in their profile
|
||||||
|
- Implicit intervals are tricky, too, as they are generated on the server, but still have to respect the user's tz, as `today` is different for a user in Cali and one in Karlsruhe
|
||||||
|
*/
|
||||||
|
|
||||||
|
func (c *dbConfig) GetDialector() gorm.Dialector {
|
||||||
|
switch c.Dialect {
|
||||||
|
case SQLDialectMysql:
|
||||||
|
return mysql.New(mysql.Config{
|
||||||
|
DriverName: c.Dialect,
|
||||||
|
DSN: mysqlConnectionString(c),
|
||||||
|
})
|
||||||
|
case SQLDialectPostgres:
|
||||||
|
return postgres.New(postgres.Config{
|
||||||
|
DSN: postgresConnectionString(c),
|
||||||
|
})
|
||||||
|
case SQLDialectSqlite:
|
||||||
|
return sqlite.Open(sqliteConnectionString(c))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mysqlConnectionString(config *dbConfig) string {
|
||||||
|
//location, _ := time.LoadLocation("Local")
|
||||||
|
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||||
|
config.User,
|
||||||
|
config.Password,
|
||||||
|
config.Host,
|
||||||
|
config.Port,
|
||||||
|
config.Name,
|
||||||
|
config.Charset,
|
||||||
|
"Local",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func postgresConnectionString(config *dbConfig) string {
|
||||||
|
sslmode := "disable"
|
||||||
|
if config.Ssl {
|
||||||
|
sslmode = "require"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=%s",
|
||||||
|
config.Host,
|
||||||
|
config.Port,
|
||||||
|
config.User,
|
||||||
|
config.Name,
|
||||||
|
config.Password,
|
||||||
|
sslmode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sqliteConnectionString(config *dbConfig) string {
|
||||||
|
return config.Name
|
||||||
|
}
|
@ -2,6 +2,7 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
|
"math"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -30,6 +31,9 @@ type StatsData struct {
|
|||||||
func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewModel {
|
func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewModel {
|
||||||
totalTime := summary.TotalTime()
|
totalTime := summary.TotalTime()
|
||||||
numDays := int(summary.ToTime.T().Sub(summary.FromTime.T()).Hours() / 24)
|
numDays := int(summary.ToTime.T().Sub(summary.FromTime.T()).Hours() / 24)
|
||||||
|
if math.IsInf(float64(numDays), 0) {
|
||||||
|
numDays = 0
|
||||||
|
}
|
||||||
|
|
||||||
data := &StatsData{
|
data := &StatsData{
|
||||||
Username: summary.UserID,
|
Username: summary.UserID,
|
||||||
|
@ -129,7 +129,6 @@ func newDataFrom(s *models.Summary) *SummariesData {
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for i, e := range s.Languages {
|
for i, e := range s.Languages {
|
||||||
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
|
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
|
||||||
|
|
||||||
}
|
}
|
||||||
}(data)
|
}(data)
|
||||||
|
|
||||||
|
@ -96,7 +96,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondJSON(w, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
|
utils.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288)
|
// construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288)
|
||||||
|
@ -51,5 +51,5 @@ func (h *SummaryApiHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondJSON(w, http.StatusOK, summary)
|
utils.RespondJSON(w, r, http.StatusOK, summary)
|
||||||
}
|
}
|
||||||
|
@ -101,7 +101,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey)
|
cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey)
|
||||||
if cacheResult, ok := h.cache.Get(cacheKey); ok {
|
if cacheResult, ok := h.cache.Get(cacheKey); ok {
|
||||||
utils.RespondJSON(w, http.StatusOK, cacheResult.(*v1.BadgeData))
|
utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,7 +114,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
vm := v1.NewBadgeDataFrom(summary, filters)
|
vm := v1.NewBadgeDataFrom(summary, filters)
|
||||||
h.cache.SetDefault(cacheKey, vm)
|
h.cache.SetDefault(cacheKey, vm)
|
||||||
utils.RespondJSON(w, http.StatusOK, vm)
|
utils.RespondJSON(w, r, http.StatusOK, vm)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey) (*models.Summary, error, int) {
|
func (h *BadgeHandler) loadUserSummary(user *models.User, interval *models.IntervalKey) (*models.Summary, error, int) {
|
||||||
|
@ -64,7 +64,7 @@ func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vm := v1.NewAllTimeFrom(summary, models.NewFiltersWith(models.SummaryProject, values.Get("project")))
|
vm := v1.NewAllTimeFrom(summary, models.NewFiltersWith(models.SummaryProject, values.Get("project")))
|
||||||
utils.RespondJSON(w, http.StatusOK, vm)
|
utils.RespondJSON(w, r, http.StatusOK, vm)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) {
|
func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) {
|
||||||
|
@ -103,7 +103,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
stats.Data.Machines = nil
|
stats.Data.Machines = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.RespondJSON(w, http.StatusOK, stats)
|
utils.RespondJSON(w, r, http.StatusOK, stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time) (*models.Summary, error, int) {
|
func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time) (*models.Summary, error, int) {
|
||||||
|
@ -76,7 +76,7 @@ func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vm := v1.NewSummariesFrom(summaries, filters)
|
vm := v1.NewSummariesFrom(summaries, filters)
|
||||||
utils.RespondJSON(w, http.StatusOK, vm)
|
utils.RespondJSON(w, r, http.StatusOK, vm)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
|
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
|
||||||
|
@ -6,10 +6,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RespondJSON(w http.ResponseWriter, status int, object interface{}) {
|
func RespondJSON(w http.ResponseWriter, r *http.Request, status int, object interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
if err := json.NewEncoder(w).Encode(object); err != nil {
|
if err := json.NewEncoder(w).Encode(object); err != nil {
|
||||||
config.Log().Error("error while writing json response: %v", err)
|
config.Log().Request(r).Error("error while writing json response: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1 +1 @@
|
|||||||
1.26.6
|
1.26.7
|
||||||
|
@ -632,11 +632,13 @@
|
|||||||
|
|
||||||
const btnImportWakatime = document.querySelector('#btn-import-wakatime')
|
const btnImportWakatime = document.querySelector('#btn-import-wakatime')
|
||||||
const formImportWakatime = document.querySelector('#form-import-wakatime')
|
const formImportWakatime = document.querySelector('#form-import-wakatime')
|
||||||
btnImportWakatime.addEventListener('click', () => {
|
if (btnImportWakatime) {
|
||||||
if (confirm('Are you sure? The import can not be undone.')) {
|
btnImportWakatime.addEventListener('click', () => {
|
||||||
formImportWakatime.submit()
|
if (confirm('Are you sure? The import can not be undone.')) {
|
||||||
}
|
formImportWakatime.submit()
|
||||||
})
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Time zone stuff
|
// Time zone stuff
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user