Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use Timezone from attendance entries #161

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion pkg/odoo/model/attendance.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ type Attendance struct {
// Reason describes the "action reason" from Odoo.
// NOTE: This field has special meaning when calculating the overtime.
Reason *ActionReason `json:"action_desc,omitempty"`

// Timezone is the custom Time location in Odoo.
// This is an extra, custom field since Odoo saves the time in UTC only, leaving out the time zone information.
Timezone *odoo.TimeZone `json:"x_timezone,omitempty"`
}

type AttendanceList odoo.List[Attendance]
Expand All @@ -47,7 +51,7 @@ func (o Odoo) fetchAttendances(ctx context.Context, domainFilters []odoo.Filter)
err := o.querier.SearchGenericModel(ctx, odoo.SearchReadModel{
Model: "hr.attendance",
Domain: domainFilters,
Fields: []string{"employee_id", "name", "action", "action_desc"},
Fields: []string{"employee_id", "name", "action", "action_desc", "x_timezone"},
Limit: 0,
Offset: 0,
}, &result)
Expand Down
15 changes: 15 additions & 0 deletions pkg/odoo/timezone.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,18 @@ func (tz *TimeZone) IsEmpty() bool {
}
return false
}

// String returns the location name.
// Returns empty string if nil.
func (tz *TimeZone) String() string {
if tz == nil || tz.Location == nil {
return ""
}
return tz.Location.String()
}

// IsEqualTo returns true if the given TimeZone is equal to other.
// If both are nil, it returns true.
func (tz *TimeZone) IsEqualTo(other *TimeZone) bool {
return tz.String() == other.String()
}
62 changes: 62 additions & 0 deletions pkg/odoo/timezone_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ import (
"github.com/stretchr/testify/require"
)

var (
zurichTZ *time.Location
vancouverTZ *time.Location
)

func init() {
zue, err := time.LoadLocation("Europe/Zurich")
if err != nil {
panic(err)
}
zurichTZ = zue
van, err := time.LoadLocation("America/Vancouver")
if err != nil {
panic(err)
}
vancouverTZ = van
}

func TestTimeZone_UnmarshalJSON(t *testing.T) {
tests := map[string]struct {
givenInput string
Expand Down Expand Up @@ -59,6 +77,50 @@ func TestTimeZone_MarshalJSON(t *testing.T) {
}
}

func TestTimeZone_IsEqualTo(t *testing.T) {
tests := map[string]struct {
givenTimeZoneA *TimeZone
givenTimeZoneB *TimeZone
expectedResult bool
}{
"BothNil": {
givenTimeZoneA: nil, givenTimeZoneB: nil,
expectedResult: true,
},
"BothNilNested": {
givenTimeZoneA: NewTimeZone(nil),
givenTimeZoneB: NewTimeZone(nil),
expectedResult: true,
},
"A_IsNil": {
givenTimeZoneA: nil,
givenTimeZoneB: NewTimeZone(vancouverTZ),
expectedResult: false,
},
"B_IsNil": {
givenTimeZoneA: NewTimeZone(vancouverTZ),
givenTimeZoneB: nil,
expectedResult: false,
},
"BothSame": {
givenTimeZoneA: NewTimeZone(zurichTZ),
givenTimeZoneB: NewTimeZone(zurichTZ),
expectedResult: true,
},
"A_NestedNil": {
givenTimeZoneA: NewTimeZone(nil),
givenTimeZoneB: NewTimeZone(zurichTZ),
expectedResult: false,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
actual := tc.givenTimeZoneA.IsEqualTo(tc.givenTimeZoneB)
assert.Equal(t, tc.expectedResult, actual, "zone not equal: zone A: %s, zone B: %s", tc.givenTimeZoneA, tc.givenTimeZoneB)
})
}
}

func mustLoadLocation(name string) *time.Location {
loc, err := time.LoadLocation(name)
if err != nil {
Expand Down
5 changes: 5 additions & 0 deletions pkg/timesheet/dailysummary.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ func (s *DailySummary) ValidateTimesheetEntries() error {
return NewValidationError(s.Date, fmt.Errorf("the reasons for shift %s and %s should be equal: start %s (%s), end %s (%s)",
model.ActionSignIn, model.ActionSignOut, shift.Start.DateTime.Format(odoo.TimeFormat), shift.Start.Reason, shift.End.DateTime.Format(odoo.TimeFormat), shift.End.Reason))
}
if !shift.Start.Timezone.IsEqualTo(shift.End.Timezone) {
return NewValidationError(s.Date, fmt.Errorf("if given, explicit timezones for attendances in a shift must be equal: start %s (%s), end: %s (%s)",
shift.Start.DateTime.Format(odoo.TimeFormat), shift.Start.Timezone, shift.End.DateTime.Format(odoo.TimeFormat), shift.End.Timezone,
))
}
totalDuration += shiftDuration
}
if totalDuration > 24*time.Hour {
Expand Down
9 changes: 9 additions & 0 deletions pkg/timesheet/dailysummary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,15 @@ func TestDailySummary_ValidateTimesheetEntries(t *testing.T) {
},
expectedError: "the reasons for shift sign_in and sign_out should be equal: start 08:00:00 (), end 10:00:00 (Sick / Medical Consultation)",
},
"ExplicitTimezoneDifferent": {
givenShifts: []AttendanceShift{
{
Start: model.Attendance{DateTime: odoo.NewDate(2021, 01, 02, 8, 0, 0, time.UTC), Action: model.ActionSignIn},
End: model.Attendance{DateTime: odoo.NewDate(2021, 01, 02, 18, 0, 0, time.UTC), Action: model.ActionSignIn, Timezone: odoo.NewTimeZone(vancouverTZ)},
},
},
expectedError: "if given, explicit timezones for attendances in a shift must be equal: start 08:00:00 (), end: 18:00:00 (America/Vancouver)",
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
Expand Down
4 changes: 3 additions & 1 deletion pkg/timesheet/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,18 +150,20 @@ func (r *ReportBuilder) getTimeZone() *time.Location {
}

func (r *ReportBuilder) addAttendancesToDailyShifts(attendances model.AttendanceList, dailies []*DailySummary) {
tz := r.getTimeZone()
monthTz := r.getTimeZone()
dailyMap := make(map[string]*DailySummary, len(dailies))
for _, dailySummary := range dailies {
dailyMap[dailySummary.Date.Format(odoo.DateFormat)] = dailySummary
}

for _, attendance := range attendances.Items {
tz := attendance.Timezone.LocationOrDefault(monthTz)
date := attendance.DateTime.In(tz)
daily, exists := dailyMap[date.Format(odoo.DateFormat)]
if !exists {
continue // irrelevant attendance
}
daily.Date = daily.Date.In(tz) // Update the timezone of the day
var shift AttendanceShift
shiftCount := len(daily.Shifts)
newShift := false
Expand Down
9 changes: 6 additions & 3 deletions pkg/timesheet/report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -442,8 +442,11 @@ func TestReportBuilder_CalculateReport(t *testing.T) {
{DateTime: odoo.NewDate(2021, 1, 5, 10, 0, 0, zurichTZ), Action: model.ActionSignIn},
{DateTime: odoo.NewDate(2021, 1, 5, 17, 0, 0, zurichTZ), Action: model.ActionSignOut}, // 7h worked

{DateTime: odoo.NewDate(2021, 1, 7, 8, 0, 0, zurichTZ), Action: model.ActionSignIn},
{DateTime: odoo.NewDate(2021, 1, 7, 17, 5, 0, zurichTZ), Action: model.ActionSignOut}, // faked signed out, still working though
{DateTime: odoo.NewDate(2021, 1, 7, 10, 0, 0, vancouverTZ), Action: model.ActionSignIn, Timezone: odoo.NewTimeZone(vancouverTZ)},
{DateTime: odoo.NewDate(2021, 1, 7, 18, 0, 0, vancouverTZ), Action: model.ActionSignIn, Timezone: odoo.NewTimeZone(vancouverTZ)}, // 8h worked

{DateTime: odoo.NewDate(2021, 1, 8, 8, 0, 0, zurichTZ), Action: model.ActionSignIn},
{DateTime: odoo.NewDate(2021, 1, 8, 17, 5, 0, zurichTZ), Action: model.ActionSignOut}, // faked signed out, still working though
}}
givenLeaves := odoo.List[model.Leave]{Items: []model.Leave{
{DateFrom: odoo.NewDate(2021, 01, 06, 0, 0, 0, zurichTZ), DateTo: odoo.NewDate(2021, 01, 06, 23, 59, 0, zurichTZ), Type: &model.LeaveType{Name: TypeLegalLeavesPrefix}, State: StateApproved},
Expand All @@ -463,7 +466,7 @@ func TestReportBuilder_CalculateReport(t *testing.T) {
report, err := b.CalculateReport(start, end)
assert.NoError(t, err)
assert.Equal(t, report.Employee.Name, givenEmployee.Name, "employee name")
assert.Equal(t, ((9+3+7+9)*time.Hour)+(5*time.Minute), report.Summary.TotalWorkedTime, "total worked time")
assert.Equal(t, ((9+3+7+9+8)*time.Hour)+(5*time.Minute), report.Summary.TotalWorkedTime, "total worked time")
assert.Equal(t, ((1+3+1)*time.Hour)+(5*time.Minute), report.Summary.TotalOvertime, "total over time")
assert.Equal(t, 1.0, report.Summary.TotalLeave, "total leave")
assert.Equal(t, (8+2)*time.Hour, report.Summary.TotalExcusedTime, "total excused time")
Expand Down
5 changes: 4 additions & 1 deletion pkg/web/controller/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func (v BaseView) GetPreviousMonth(year, month int) (int, int) {
}

// FormatDailySummary returns Values with sensible format.
func (v BaseView) FormatDailySummary(daily *timesheet.DailySummary) Values {
func (v BaseView) FormatDailySummary(report timesheet.Report, daily *timesheet.DailySummary) Values {
overtimeSummary := daily.CalculateOvertimeSummary()
basic := Values{
"Weekday": daily.Date.Weekday(),
Expand All @@ -71,6 +71,9 @@ func (v BaseView) FormatDailySummary(daily *timesheet.DailySummary) Values {
if daily.HasAbsences() {
basic["LeaveType"] = daily.Absences[0].Reason
}
if report.From.Location() != daily.Date.Location() {
basic["Timezone"] = daily.Date.Location().String()
}
return basic
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/web/overtimereport/monthlyreport_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func (v *monthlyReportView) GetValuesForMonthlyReport(report timesheet.BalanceRe
if summary.IsWeekend() && summary.CalculateOvertimeSummary().WorkingTime() == 0 {
continue
}
values := v.FormatDailySummary(summary)
values := v.FormatDailySummary(report.Report, summary)
if values["ValidationError"] != nil {
hasInvalidAttendances = "Your timesheet contains errors."
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/web/reportconfig/config_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func (v *ConfigView) GetConfigurationValues(report timesheet.Report) controller.
if summary.IsWeekend() && summary.CalculateOvertimeSummary().WorkingTime() == 0 {
continue
}
formatted = append(formatted, v.FormatDailySummary(summary))
formatted = append(formatted, v.FormatDailySummary(report, summary))
}
summary := controller.Values{
"TotalOvertime": v.FormatDurationInHours(report.Summary.TotalOvertime),
Expand Down
2 changes: 1 addition & 1 deletion templates/overtimereport-monthly.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ <h1>Attendance for {{ .Username }}<small class="text-muted"> {{ .MonthDisplayNam
{{ range .Attendances }}
<tr>
<td>{{ .Weekday }}{{ with .ValidationError }}<br>⚠️ {{ . }}{{ end }}</td>
<td>{{ .Date }}</td>
<td>{{ .Date }}{{ if .Timezone }} <small class="text-muted">{{ .Timezone }}</small>{{ end }}</td>
<td>{{ .Workload }}%</td>
<td>{{ .LeaveType }}</td>
<td class="text-end font-monospace">{{ .ExcusedHours }}</td>
Expand Down