mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
feat: total heartbeats per summary (resolve #283)
This commit is contained in:
parent
e14f8c1463
commit
92f6d44606
@ -16,6 +16,7 @@ type Duration struct {
|
|||||||
Editor string `json:"editor"`
|
Editor string `json:"editor"`
|
||||||
OperatingSystem string `json:"operating_system"`
|
OperatingSystem string `json:"operating_system"`
|
||||||
Machine string `json:"machine"`
|
Machine string `json:"machine"`
|
||||||
|
NumHeartbeats int `json:"-" hash:"ignore"`
|
||||||
GroupHash string `json:"-" hash:"ignore"`
|
GroupHash string `json:"-" hash:"ignore"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,6 +30,7 @@ func NewDurationFromHeartbeat(h *Heartbeat) *Duration {
|
|||||||
Editor: h.Editor,
|
Editor: h.Editor,
|
||||||
OperatingSystem: h.OperatingSystem,
|
OperatingSystem: h.OperatingSystem,
|
||||||
Machine: h.Machine,
|
Machine: h.Machine,
|
||||||
|
NumHeartbeats: 1,
|
||||||
}
|
}
|
||||||
return d.Hashed()
|
return d.Hashed()
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,14 @@ func (d Durations) Swap(i, j int) {
|
|||||||
d[i], d[j] = d[j], d[i]
|
d[i], d[j] = d[j], d[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d Durations) TotalNumHeartbeats() int {
|
||||||
|
var total int
|
||||||
|
for _, e := range d {
|
||||||
|
total += e.NumHeartbeats
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
func (d Durations) Sorted() Durations {
|
func (d Durations) Sorted() Durations {
|
||||||
sort.Sort(d)
|
sort.Sort(d)
|
||||||
return d
|
return d
|
||||||
|
@ -31,6 +31,7 @@ type Summary struct {
|
|||||||
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
Labels SummaryItems `json:"labels" gorm:"-"` // labels are not persisted, but calculated at runtime, i.e. when summary is retrieved
|
Labels SummaryItems `json:"labels" gorm:"-"` // labels are not persisted, but calculated at runtime, i.e. when summary is retrieved
|
||||||
|
NumHeartbeats int `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SummaryItems []*SummaryItem
|
type SummaryItems []*SummaryItem
|
||||||
|
@ -57,6 +57,8 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User) (models.D
|
|||||||
mapping[d1.GroupHash] = append(mapping[d1.GroupHash], d1)
|
mapping[d1.GroupHash] = append(mapping[d1.GroupHash], d1)
|
||||||
}
|
}
|
||||||
latest = d1
|
latest = d1
|
||||||
|
} else {
|
||||||
|
latest.NumHeartbeats++
|
||||||
}
|
}
|
||||||
|
|
||||||
count++
|
count++
|
||||||
|
@ -140,6 +140,7 @@ func (suite *DurationServiceTestSuite) TestDurationService_Get() {
|
|||||||
assert.Nil(suite.T(), err)
|
assert.Nil(suite.T(), err)
|
||||||
assert.Len(suite.T(), durations, 1)
|
assert.Len(suite.T(), durations, 1)
|
||||||
assert.Equal(suite.T(), HeartbeatDiffThreshold, durations.First().Duration)
|
assert.Equal(suite.T(), HeartbeatDiffThreshold, durations.First().Duration)
|
||||||
|
assert.Equal(suite.T(), 1, durations.First().NumHeartbeats)
|
||||||
|
|
||||||
/* TEST 3 */
|
/* TEST 3 */
|
||||||
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
|
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
|
||||||
@ -155,6 +156,9 @@ func (suite *DurationServiceTestSuite) TestDurationService_Get() {
|
|||||||
assert.Equal(suite.T(), TestEditorGoland, durations[0].Editor)
|
assert.Equal(suite.T(), TestEditorGoland, durations[0].Editor)
|
||||||
assert.Equal(suite.T(), TestEditorGoland, durations[1].Editor)
|
assert.Equal(suite.T(), TestEditorGoland, durations[1].Editor)
|
||||||
assert.Equal(suite.T(), TestEditorVscode, durations[2].Editor)
|
assert.Equal(suite.T(), TestEditorVscode, durations[2].Editor)
|
||||||
|
assert.Equal(suite.T(), 2, durations[0].NumHeartbeats)
|
||||||
|
assert.Equal(suite.T(), 1, durations[1].NumHeartbeats)
|
||||||
|
assert.Equal(suite.T(), 3, durations[2].NumHeartbeats)
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterHeartbeats(from, to time.Time, heartbeats []*models.Heartbeat) []*models.Heartbeat {
|
func filterHeartbeats(from, to time.Time, heartbeats []*models.Heartbeat) []*models.Heartbeat {
|
||||||
|
@ -117,10 +117,8 @@ func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*mod
|
|||||||
|
|
||||||
func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*models.Summary, error) {
|
func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*models.Summary, error) {
|
||||||
// Initialize and fetch data
|
// Initialize and fetch data
|
||||||
var durations models.Durations
|
durations, err := srv.durationService.Get(from, to, user)
|
||||||
if result, err := srv.durationService.Get(from, to, user); err == nil {
|
if err != nil {
|
||||||
durations = result
|
|
||||||
} else {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,6 +167,7 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
|
|||||||
Editors: editorItems,
|
Editors: editorItems,
|
||||||
OperatingSystems: osItems,
|
OperatingSystems: osItems,
|
||||||
Machines: machineItems,
|
Machines: machineItems,
|
||||||
|
NumHeartbeats: durations.TotalNumHeartbeats(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return summary.Sorted(), nil
|
return summary.Sorted(), nil
|
||||||
@ -303,6 +302,7 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
|
|||||||
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
||||||
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
|
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
|
||||||
finalSummary.Labels = srv.mergeSummaryItems(finalSummary.Labels, s.Labels)
|
finalSummary.Labels = srv.mergeSummaryItems(finalSummary.Labels, s.Labels)
|
||||||
|
finalSummary.NumHeartbeats += s.NumHeartbeats
|
||||||
|
|
||||||
processed[hash] = true
|
processed[hash] = true
|
||||||
}
|
}
|
||||||
|
@ -44,6 +44,7 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
|
|||||||
Machine: TestMachine1,
|
Machine: TestMachine1,
|
||||||
Time: models.CustomTime(suite.TestStartTime),
|
Time: models.CustomTime(suite.TestStartTime),
|
||||||
Duration: 150 * time.Second,
|
Duration: 150 * time.Second,
|
||||||
|
NumHeartbeats: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
UserID: TestUserId,
|
UserID: TestUserId,
|
||||||
@ -54,6 +55,7 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
|
|||||||
Machine: TestMachine1,
|
Machine: TestMachine1,
|
||||||
Time: models.CustomTime(suite.TestStartTime.Add((30 + 130) * time.Second)),
|
Time: models.CustomTime(suite.TestStartTime.Add((30 + 130) * time.Second)),
|
||||||
Duration: 20 * time.Second,
|
Duration: 20 * time.Second,
|
||||||
|
NumHeartbeats: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
UserID: TestUserId,
|
UserID: TestUserId,
|
||||||
@ -64,6 +66,7 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
|
|||||||
Machine: TestMachine1,
|
Machine: TestMachine1,
|
||||||
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)),
|
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)),
|
||||||
Duration: 15 * time.Second,
|
Duration: 15 * time.Second,
|
||||||
|
NumHeartbeats: 3,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
suite.TestLabels = []*models.ProjectLabel{
|
suite.TestLabels = []*models.ProjectLabel{
|
||||||
@ -114,6 +117,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
|||||||
assert.Equal(suite.T(), from, result.FromTime.T())
|
assert.Equal(suite.T(), from, result.FromTime.T())
|
||||||
assert.Equal(suite.T(), to, result.ToTime.T())
|
assert.Equal(suite.T(), to, result.ToTime.T())
|
||||||
assert.Zero(suite.T(), result.TotalTime())
|
assert.Zero(suite.T(), result.TotalTime())
|
||||||
|
assert.Zero(suite.T(), result.NumHeartbeats)
|
||||||
assert.Empty(suite.T(), result.Projects)
|
assert.Empty(suite.T(), result.Projects)
|
||||||
|
|
||||||
/* TEST 2 */
|
/* TEST 2 */
|
||||||
@ -127,6 +131,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
|||||||
assert.Equal(suite.T(), suite.TestDurations[0].Time.T(), result.FromTime.T())
|
assert.Equal(suite.T(), suite.TestDurations[0].Time.T(), result.FromTime.T())
|
||||||
assert.Equal(suite.T(), suite.TestDurations[0].Time.T(), result.ToTime.T())
|
assert.Equal(suite.T(), suite.TestDurations[0].Time.T(), result.ToTime.T())
|
||||||
assert.Equal(suite.T(), 150*time.Second, result.TotalTime())
|
assert.Equal(suite.T(), 150*time.Second, result.TotalTime())
|
||||||
|
assert.Equal(suite.T(), 2, result.NumHeartbeats)
|
||||||
assertNumAllItems(suite.T(), 1, result, "")
|
assertNumAllItems(suite.T(), 1, result, "")
|
||||||
|
|
||||||
/* TEST 3 */
|
/* TEST 3 */
|
||||||
@ -142,6 +147,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
|||||||
assert.Equal(suite.T(), 185*time.Second, result.TotalTime())
|
assert.Equal(suite.T(), 185*time.Second, result.TotalTime())
|
||||||
assert.Equal(suite.T(), 170*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland))
|
assert.Equal(suite.T(), 170*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland))
|
||||||
assert.Equal(suite.T(), 15*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode))
|
assert.Equal(suite.T(), 15*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode))
|
||||||
|
assert.Equal(suite.T(), 6, result.NumHeartbeats)
|
||||||
assert.Len(suite.T(), result.Editors, 2)
|
assert.Len(suite.T(), result.Editors, 2)
|
||||||
assertNumAllItems(suite.T(), 1, result, "e")
|
assertNumAllItems(suite.T(), 1, result, "e")
|
||||||
}
|
}
|
||||||
@ -176,6 +182,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
|||||||
Editors: []*models.SummaryItem{},
|
Editors: []*models.SummaryItem{},
|
||||||
OperatingSystems: []*models.SummaryItem{},
|
OperatingSystems: []*models.SummaryItem{},
|
||||||
Machines: []*models.SummaryItem{},
|
Machines: []*models.SummaryItem{},
|
||||||
|
NumHeartbeats: 100,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,6 +196,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
|||||||
assert.NotNil(suite.T(), result)
|
assert.NotNil(suite.T(), result)
|
||||||
assert.Len(suite.T(), result.Projects, 1)
|
assert.Len(suite.T(), result.Projects, 1)
|
||||||
assert.Equal(suite.T(), summaries[0].Projects[0].Total*time.Second, result.TotalTime())
|
assert.Equal(suite.T(), summaries[0].Projects[0].Total*time.Second, result.TotalTime())
|
||||||
|
assert.Equal(suite.T(), 100, result.NumHeartbeats)
|
||||||
suite.DurationService.AssertNumberOfCalls(suite.T(), "Get", 2)
|
suite.DurationService.AssertNumberOfCalls(suite.T(), "Get", 2)
|
||||||
|
|
||||||
/* TEST 2 */
|
/* TEST 2 */
|
||||||
@ -210,6 +218,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
|||||||
Editors: []*models.SummaryItem{},
|
Editors: []*models.SummaryItem{},
|
||||||
OperatingSystems: []*models.SummaryItem{},
|
OperatingSystems: []*models.SummaryItem{},
|
||||||
Machines: []*models.SummaryItem{},
|
Machines: []*models.SummaryItem{},
|
||||||
|
NumHeartbeats: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: uint(rand.Uint32()),
|
ID: uint(rand.Uint32()),
|
||||||
@ -227,6 +236,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
|||||||
Editors: []*models.SummaryItem{},
|
Editors: []*models.SummaryItem{},
|
||||||
OperatingSystems: []*models.SummaryItem{},
|
OperatingSystems: []*models.SummaryItem{},
|
||||||
Machines: []*models.SummaryItem{},
|
Machines: []*models.SummaryItem{},
|
||||||
|
NumHeartbeats: 100,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,6 +251,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
|||||||
assert.Equal(suite.T(), 185*time.Second+90*time.Minute, result.TotalTime())
|
assert.Equal(suite.T(), 185*time.Second+90*time.Minute, result.TotalTime())
|
||||||
assert.Equal(suite.T(), 185*time.Second+45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject1))
|
assert.Equal(suite.T(), 185*time.Second+45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject1))
|
||||||
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject2))
|
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject2))
|
||||||
|
assert.Equal(suite.T(), 206, result.NumHeartbeats)
|
||||||
suite.DurationService.AssertNumberOfCalls(suite.T(), "Get", 2+1)
|
suite.DurationService.AssertNumberOfCalls(suite.T(), "Get", 2+1)
|
||||||
|
|
||||||
/* TEST 3 */
|
/* TEST 3 */
|
||||||
@ -263,6 +274,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
|||||||
Editors: []*models.SummaryItem{},
|
Editors: []*models.SummaryItem{},
|
||||||
OperatingSystems: []*models.SummaryItem{},
|
OperatingSystems: []*models.SummaryItem{},
|
||||||
Machines: []*models.SummaryItem{},
|
Machines: []*models.SummaryItem{},
|
||||||
|
NumHeartbeats: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: uint(rand.Uint32()),
|
ID: uint(rand.Uint32()),
|
||||||
@ -280,6 +292,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
|||||||
Editors: []*models.SummaryItem{},
|
Editors: []*models.SummaryItem{},
|
||||||
OperatingSystems: []*models.SummaryItem{},
|
OperatingSystems: []*models.SummaryItem{},
|
||||||
Machines: []*models.SummaryItem{},
|
Machines: []*models.SummaryItem{},
|
||||||
|
NumHeartbeats: 100,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -294,6 +307,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
|||||||
assert.Equal(suite.T(), 90*time.Minute, result.TotalTime())
|
assert.Equal(suite.T(), 90*time.Minute, result.TotalTime())
|
||||||
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject1))
|
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject1))
|
||||||
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject2))
|
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject2))
|
||||||
|
assert.Equal(suite.T(), 200, result.NumHeartbeats)
|
||||||
suite.DurationService.AssertNumberOfCalls(suite.T(), "Get", 2+1+1)
|
suite.DurationService.AssertNumberOfCalls(suite.T(), "Get", 2+1+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -384,6 +398,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
|
|||||||
assert.NotNil(suite.T(), result)
|
assert.NotNil(suite.T(), result)
|
||||||
assert.Zero(suite.T(), result.TotalTimeByKey(models.SummaryProject, TestProject1))
|
assert.Zero(suite.T(), result.TotalTimeByKey(models.SummaryProject, TestProject1))
|
||||||
assert.NotZero(suite.T(), result.TotalTimeByKey(models.SummaryProject, TestProject2))
|
assert.NotZero(suite.T(), result.TotalTimeByKey(models.SummaryProject, TestProject2))
|
||||||
|
assert.Equal(suite.T(), 6, result.NumHeartbeats)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased_ProjectLabels() {
|
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased_ProjectLabels() {
|
||||||
@ -422,6 +437,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased_ProjectLabels()
|
|||||||
assert.Nil(suite.T(), err)
|
assert.Nil(suite.T(), err)
|
||||||
assert.NotNil(suite.T(), result)
|
assert.NotNil(suite.T(), result)
|
||||||
assert.Equal(suite.T(), 195*time.Second, result.TotalTimeByKey(models.SummaryLabel, TestProjectLabel1))
|
assert.Equal(suite.T(), 195*time.Second, result.TotalTimeByKey(models.SummaryLabel, TestProjectLabel1))
|
||||||
|
assert.Equal(suite.T(), 6, result.NumHeartbeats)
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterDurations(from, to time.Time, durations models.Durations) models.Durations {
|
func filterDurations(from, to time.Time, durations models.Durations) models.Durations {
|
||||||
|
2
static/assets/vendor/twemoji.min.js
vendored
2
static/assets/vendor/twemoji.min.js
vendored
File diff suppressed because one or more lines are too long
@ -69,6 +69,10 @@
|
|||||||
<span class="text-xs text-gray-500 font-semibold">Total Time</span>
|
<span class="text-xs text-gray-500 font-semibold">Total Time</span>
|
||||||
<span class="font-semibold text-xl truncate">{{ .TotalTime | duration }}</span>
|
<span class="font-semibold text-xl truncate">{{ .TotalTime | duration }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col space-y-2 w-40 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
|
||||||
|
<span class="text-xs text-gray-500 font-semibold">Total Heartbeats</span>
|
||||||
|
<span class="font-semibold text-xl truncate">{{ .NumHeartbeats }}</span>
|
||||||
|
</div>
|
||||||
<div class="flex flex-col space-y-2 w-40 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
|
<div class="flex flex-col space-y-2 w-40 p-4 rounded-md p-4 text-gray-300 bg-gray-850 leading-none border-2 border-green-700">
|
||||||
<span class="text-xs text-gray-500 font-semibold">Top Project</span>
|
<span class="text-xs text-gray-500 font-semibold">Top Project</span>
|
||||||
<span class="font-semibold text-xl truncate">{{ .MaxByToString 0 }}</span>
|
<span class="font-semibold text-xl truncate">{{ .MaxByToString 0 }}</span>
|
||||||
@ -170,7 +174,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-full lg:w-1/2 p-1" style="max-width: 100vw;">
|
<div class="w-full lg:w-1/2 p-1" style="max-width: 100vw;">
|
||||||
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col" id="label-container" style="height: 300px">
|
<div class="p-4 px-6 pb-10 bg-gray-850 text-gray-300 rounded-md shadow flex flex-col" id="label-container" style="height: 300px">
|
||||||
<div class="flex justify-between text-lg">
|
<div class="flex justify-between text-lg" style="margin-bottom: -10px">
|
||||||
<span class="font-semibold whitespace-nowrap">Labels</span>
|
<span class="font-semibold whitespace-nowrap">Labels</span>
|
||||||
<a href="settings#data" class="ml-4 inline p-2 hover:bg-gray-800 rounded" style="margin-top: -5px">
|
<a href="settings#data" class="ml-4 inline p-2 hover:bg-gray-800 rounded" style="margin-top: -5px">
|
||||||
<span class="iconify inline" data-icon="twemoji:gear"></span>
|
<span class="iconify inline" data-icon="twemoji:gear"></span>
|
||||||
|
Loading…
Reference in New Issue
Block a user