mirror of
https://github.com/muety/wakapi.git
synced 2025-12-06 06:22:41 -08:00
feat: ability to exlclude unknown projects from summaries (resolve #619)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,11 @@ func (m *UserServiceMock) GetAll() ([]*models.User, error) {
|
||||
return args.Get(0).([]*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetAllMapped() (map[string]*models.User, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).(map[string]*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetMany(s []string) ([]*models.User, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).([]*models.User), args.Error(1)
|
||||
|
||||
@@ -15,31 +15,32 @@ func init() {
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id" gorm:"primary_key"`
|
||||
ApiKey string `json:"api_key" gorm:"unique; default:NULL"`
|
||||
Email string `json:"email" gorm:"index:idx_user_email; size:255"`
|
||||
Location string `json:"location"`
|
||||
Password string `json:"-"`
|
||||
CreatedAt CustomTime `gorm:"default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
LastLoggedInAt CustomTime `gorm:"default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
ShareDataMaxDays int `json:"-"`
|
||||
ShareEditors bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareLanguages bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
|
||||
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareLabels bool `json:"-" gorm:"default:false; type:bool"`
|
||||
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
|
||||
HasData bool `json:"-" gorm:"default:false; type:bool"`
|
||||
WakatimeApiKey string `json:"-"` // for relay middleware and imports
|
||||
WakatimeApiUrl string `json:"-"` // for relay middleware and imports
|
||||
ResetToken string `json:"-"`
|
||||
ReportsWeekly bool `json:"-" gorm:"default:false; type:bool"`
|
||||
PublicLeaderboard bool `json:"-" gorm:"default:false; type:bool"`
|
||||
SubscribedUntil *CustomTime `json:"-" gorm:"swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
SubscriptionRenewal *CustomTime `json:"-" gorm:"swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
StripeCustomerId string `json:"-"`
|
||||
InvitedBy string `json:"-"`
|
||||
ID string `json:"id" gorm:"primary_key"`
|
||||
ApiKey string `json:"api_key" gorm:"unique; default:NULL"`
|
||||
Email string `json:"email" gorm:"index:idx_user_email; size:255"`
|
||||
Location string `json:"location"`
|
||||
Password string `json:"-"`
|
||||
CreatedAt CustomTime `gorm:"default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
LastLoggedInAt CustomTime `gorm:"default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
ShareDataMaxDays int `json:"-"`
|
||||
ShareEditors bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareLanguages bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
|
||||
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareLabels bool `json:"-" gorm:"default:false; type:bool"`
|
||||
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
|
||||
HasData bool `json:"-" gorm:"default:false; type:bool"`
|
||||
WakatimeApiKey string `json:"-"` // for relay middleware and imports
|
||||
WakatimeApiUrl string `json:"-"` // for relay middleware and imports
|
||||
ResetToken string `json:"-"`
|
||||
ReportsWeekly bool `json:"-" gorm:"default:false; type:bool"`
|
||||
PublicLeaderboard bool `json:"-" gorm:"default:false; type:bool"`
|
||||
SubscribedUntil *CustomTime `json:"-" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
SubscriptionRenewal *CustomTime `json:"-" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
StripeCustomerId string `json:"-"`
|
||||
InvitedBy string `json:"-"`
|
||||
ExcludeUnknownProjects bool `json:"-"`
|
||||
}
|
||||
|
||||
type Login struct {
|
||||
|
||||
@@ -127,28 +127,29 @@ func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, err
|
||||
|
||||
func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
||||
updateMap := map[string]interface{}{
|
||||
"api_key": user.ApiKey,
|
||||
"password": user.Password,
|
||||
"email": user.Email,
|
||||
"last_logged_in_at": user.LastLoggedInAt,
|
||||
"share_data_max_days": user.ShareDataMaxDays,
|
||||
"share_editors": user.ShareEditors,
|
||||
"share_languages": user.ShareLanguages,
|
||||
"share_oss": user.ShareOSs,
|
||||
"share_projects": user.ShareProjects,
|
||||
"share_machines": user.ShareMachines,
|
||||
"share_labels": user.ShareLabels,
|
||||
"wakatime_api_key": user.WakatimeApiKey,
|
||||
"wakatime_api_url": user.WakatimeApiUrl,
|
||||
"has_data": user.HasData,
|
||||
"reset_token": user.ResetToken,
|
||||
"location": user.Location,
|
||||
"reports_weekly": user.ReportsWeekly,
|
||||
"public_leaderboard": user.PublicLeaderboard,
|
||||
"subscribed_until": user.SubscribedUntil,
|
||||
"subscription_renewal": user.SubscriptionRenewal,
|
||||
"stripe_customer_id": user.StripeCustomerId,
|
||||
"invited_by": user.InvitedBy,
|
||||
"api_key": user.ApiKey,
|
||||
"password": user.Password,
|
||||
"email": user.Email,
|
||||
"last_logged_in_at": user.LastLoggedInAt,
|
||||
"share_data_max_days": user.ShareDataMaxDays,
|
||||
"share_editors": user.ShareEditors,
|
||||
"share_languages": user.ShareLanguages,
|
||||
"share_oss": user.ShareOSs,
|
||||
"share_projects": user.ShareProjects,
|
||||
"share_machines": user.ShareMachines,
|
||||
"share_labels": user.ShareLabels,
|
||||
"wakatime_api_key": user.WakatimeApiKey,
|
||||
"wakatime_api_url": user.WakatimeApiUrl,
|
||||
"has_data": user.HasData,
|
||||
"reset_token": user.ResetToken,
|
||||
"location": user.Location,
|
||||
"reports_weekly": user.ReportsWeekly,
|
||||
"public_leaderboard": user.PublicLeaderboard,
|
||||
"subscribed_until": user.SubscribedUntil,
|
||||
"subscription_renewal": user.SubscriptionRenewal,
|
||||
"stripe_customer_id": user.StripeCustomerId,
|
||||
"invited_by": user.InvitedBy,
|
||||
"exclude_unknown_projects": user.ExcludeUnknownProjects,
|
||||
}
|
||||
|
||||
result := r.db.Model(user).Updates(updateMap)
|
||||
|
||||
@@ -39,6 +39,7 @@ type SettingsHandler struct {
|
||||
keyValueSrvc services.IKeyValueService
|
||||
mailSrvc services.IMailService
|
||||
httpClient *http.Client
|
||||
aggregationLocks map[string]bool
|
||||
}
|
||||
|
||||
type action func(w http.ResponseWriter, r *http.Request) actionResult
|
||||
@@ -77,6 +78,7 @@ func NewSettingsHandler(
|
||||
keyValueSrvc: keyValueService,
|
||||
mailSrvc: mailService,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
aggregationLocks: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +180,8 @@ func (h *SettingsHandler) dispatchAction(action string) action {
|
||||
return h.actionDeleteUser
|
||||
case "generate_invite":
|
||||
return h.actionGenerateInvite
|
||||
case "update_unknown_projects":
|
||||
return h.actionUpdateExcludeUnknownProjects
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -298,6 +302,39 @@ func (h *SettingsHandler) actionUpdateLeaderboard(w http.ResponseWriter, r *http
|
||||
return actionResult{http.StatusOK, "settings updated", "", nil}
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) actionUpdateExcludeUnknownProjects(w http.ResponseWriter, r *http.Request) actionResult {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
var err error
|
||||
user := middlewares.GetPrincipal(r)
|
||||
defer h.userSrvc.FlushCache()
|
||||
|
||||
if h.isAggregationLocked(user.ID) {
|
||||
return actionResult{http.StatusConflict, "", "summary regeneration already in progress, please wait", nil}
|
||||
}
|
||||
|
||||
user.ExcludeUnknownProjects, err = strconv.ParseBool(r.PostFormValue("exclude_unknown_projects"))
|
||||
|
||||
if err != nil {
|
||||
return actionResult{http.StatusBadRequest, "", "invalid input", nil}
|
||||
}
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
return actionResult{http.StatusInternalServerError, "", "internal sever error", nil}
|
||||
}
|
||||
|
||||
go func(user *models.User) {
|
||||
h.toggleAggregationLock(user.ID, true)
|
||||
defer h.toggleAggregationLock(user.ID, false)
|
||||
if err := h.regenerateSummaries(user); err != nil {
|
||||
conf.Log().Request(r).Error("failed to regenerate summaries for user '%s' - %v", user.ID, err)
|
||||
}
|
||||
}(user)
|
||||
|
||||
return actionResult{http.StatusOK, "regenerating summaries, this might take a while", "", nil}
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Request) actionResult {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
@@ -627,11 +664,19 @@ func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *ht
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := middlewares.GetPrincipal(r)
|
||||
|
||||
if h.isAggregationLocked(user.ID) {
|
||||
return actionResult{http.StatusConflict, "", "summary regeneration already in progress, please wait", nil}
|
||||
}
|
||||
|
||||
go func(user *models.User) {
|
||||
h.toggleAggregationLock(user.ID, true)
|
||||
defer h.toggleAggregationLock(user.ID, false)
|
||||
if err := h.regenerateSummaries(user); err != nil {
|
||||
conf.Log().Request(r).Error("failed to regenerate summaries for user '%s' - %v", user.ID, err)
|
||||
}
|
||||
}(middlewares.GetPrincipal(r))
|
||||
}(user)
|
||||
|
||||
return actionResult{http.StatusAccepted, "summaries are being regenerated - this may take a up to a couple of minutes, please come back later", "", nil}
|
||||
}
|
||||
@@ -850,6 +895,15 @@ func (h *SettingsHandler) buildViewModel(r *http.Request, w http.ResponseWriter,
|
||||
return routeutils.WithSessionMessages(vm, r, w)
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) toggleAggregationLock(userId string, locked bool) {
|
||||
h.aggregationLocks[userId] = locked
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) isAggregationLocked(userId string) bool {
|
||||
locked, _ := h.aggregationLocks[userId]
|
||||
return locked
|
||||
}
|
||||
|
||||
func getVal[T any](values *map[string]interface{}, key string, fallback T) T {
|
||||
if values == nil {
|
||||
return fallback
|
||||
|
||||
@@ -41,9 +41,9 @@ func NewAggregationService(userService IUserService, summaryService ISummaryServ
|
||||
}
|
||||
|
||||
type AggregationJob struct {
|
||||
UserID string
|
||||
From time.Time
|
||||
To time.Time
|
||||
User *models.User
|
||||
From time.Time
|
||||
To time.Time
|
||||
}
|
||||
|
||||
// Schedule a job to (re-)generate summaries every day shortly after midnight
|
||||
@@ -96,25 +96,38 @@ func (srv *AggregationService) AggregateSummaries(userIds datastructure.Set[stri
|
||||
if err := srv.queueWorkers.Dispatch(func() {
|
||||
srv.process(job)
|
||||
}); err != nil {
|
||||
config.Log().Error("failed to dispatch summary generation job for user '%s'", job.UserID)
|
||||
config.Log().Error("failed to dispatch summary generation job for user '%s'", job.User.ID)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Fetch complete user objects
|
||||
var users map[string]*models.User
|
||||
if userIds != nil && !userIds.IsEmpty() {
|
||||
users, err = srv.userService.GetManyMapped(userIds.Values())
|
||||
} else {
|
||||
users, err = srv.userService.GetAllMapped()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate summary aggregation jobs
|
||||
for _, e := range lastUserSummaryTimes {
|
||||
if userIds != nil && !userIds.IsEmpty() && !userIds.Contain(e.User) {
|
||||
continue
|
||||
}
|
||||
|
||||
u, _ := users[e.User]
|
||||
|
||||
if e.Time.Valid() {
|
||||
// Case 1: User has aggregated summaries already
|
||||
// -> Spawn jobs to create summaries from their latest aggregation to now
|
||||
generateUserJobs(e.User, e.Time.T(), jobs)
|
||||
generateUserJobs(u, e.Time.T(), jobs)
|
||||
} else if t := firstUserHeartbeatLookup[e.User]; t.Valid() {
|
||||
// Case 2: User has no aggregated summaries, yet, but has heartbeats
|
||||
// -> Spawn jobs to create summaries from their first heartbeat to now
|
||||
generateUserJobs(e.User, t.T(), jobs)
|
||||
generateUserJobs(u, t.T(), jobs)
|
||||
}
|
||||
// Case 3: User doesn't have heartbeats at all
|
||||
// -> Nothing to do
|
||||
@@ -124,17 +137,17 @@ func (srv *AggregationService) AggregateSummaries(userIds datastructure.Set[stri
|
||||
}
|
||||
|
||||
func (srv *AggregationService) process(job AggregationJob) {
|
||||
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}, nil); err != nil {
|
||||
config.Log().Error("failed to generate summary (%v, %v, %s) - %v", job.From, job.To, job.UserID, err)
|
||||
if summary, err := srv.summaryService.Summarize(job.From, job.To, job.User, nil); err != nil {
|
||||
config.Log().Error("failed to generate summary (%v, %v, %s) - %v", job.From, job.To, job.User.ID, err)
|
||||
} else {
|
||||
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
|
||||
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.User.ID)
|
||||
if err := srv.summaryService.Insert(summary); err != nil {
|
||||
config.Log().Error("failed to save summary (%v, %v, %s) - %v", summary.UserID, summary.FromTime, summary.ToTime, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) {
|
||||
func generateUserJobs(user *models.User, from time.Time, jobs chan<- *AggregationJob) {
|
||||
var to time.Time
|
||||
|
||||
// Go to next day of either user's first heartbeat or latest aggregation
|
||||
@@ -157,7 +170,7 @@ func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob
|
||||
0, 0, 0, 0,
|
||||
from.Location(),
|
||||
)
|
||||
jobs <- &AggregationJob{userId, from, to}
|
||||
jobs <- &AggregationJob{user, from, to}
|
||||
from = to
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,10 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *
|
||||
continue
|
||||
}
|
||||
|
||||
if user.ExcludeUnknownProjects && d.Project == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// will only happen if two heartbeats with different hashes (e.g. different project) have the same timestamp
|
||||
// that, in turn, will most likely only happen for mysql, where `time` column's precision was set to second for a while
|
||||
// assume that two non-identical heartbeats with identical time are sub-second apart from each other, so round up to expectancy value
|
||||
|
||||
@@ -135,6 +135,7 @@ type IUserService interface {
|
||||
GetUserByResetToken(string) (*models.User, error)
|
||||
GetUserByStripeCustomerId(string) (*models.User, error)
|
||||
GetAll() ([]*models.User, error)
|
||||
GetAllMapped() (map[string]*models.User, error)
|
||||
GetMany([]string) ([]*models.User, error)
|
||||
GetManyMapped([]string) (map[string]*models.User, error)
|
||||
GetAllByReports(bool) ([]*models.User, error)
|
||||
|
||||
@@ -119,6 +119,14 @@ func (srv *UserService) GetAll() ([]*models.User, error) {
|
||||
return srv.repository.GetAll()
|
||||
}
|
||||
|
||||
func (srv *UserService) GetAllMapped() (map[string]*models.User, error) {
|
||||
users, err := srv.repository.GetAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return srv.MapUsersById(users), nil
|
||||
}
|
||||
|
||||
func (srv *UserService) GetMany(ids []string) ([]*models.User, error) {
|
||||
return srv.repository.GetMany(ids)
|
||||
}
|
||||
@@ -128,9 +136,7 @@ func (srv *UserService) GetManyMapped(ids []string) (map[string]*models.User, er
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertor.ToMap[*models.User, string, *models.User](users, func(u *models.User) (string, *models.User) {
|
||||
return u.ID, u
|
||||
}), nil
|
||||
return srv.MapUsersById(users), nil
|
||||
}
|
||||
|
||||
func (srv *UserService) GetAllByReports(reportsEnabled bool) ([]*models.User, error) {
|
||||
@@ -227,6 +233,12 @@ func (srv *UserService) Delete(user *models.User) error {
|
||||
return srv.repository.Delete(user)
|
||||
}
|
||||
|
||||
func (srv *UserService) MapUsersById(users []*models.User) map[string]*models.User {
|
||||
return convertor.ToMap[*models.User, string, *models.User](users, func(u *models.User) (string, *models.User) {
|
||||
return u.ID, u
|
||||
})
|
||||
}
|
||||
|
||||
func (srv *UserService) FlushCache() {
|
||||
srv.cache.Flush()
|
||||
}
|
||||
|
||||
@@ -189,4 +189,8 @@ body {
|
||||
#summary-page svg rect {
|
||||
cursor: help;
|
||||
}
|
||||
*/
|
||||
*/
|
||||
|
||||
.wi-min {
|
||||
width: min-content !important;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -203,6 +203,39 @@
|
||||
</div>
|
||||
|
||||
<div v-cloak id="data" class="tab flex flex-col space-y-4" v-if="isActive('data')">
|
||||
|
||||
<!-- Unknown Projects -->
|
||||
<form class="w-full" action="" method="post">
|
||||
<input type="hidden" name="action" value="update_unknown_projects">
|
||||
<div class="flex flex-wrap md:flex-nowrap mb-2 gap-x-4">
|
||||
<div class="w-full md:w-1/3 mb-2 md:mb-0 inline-block">
|
||||
<span class="font-semibold text-gray-300 text-lg">Unknown Projects</span>
|
||||
<p class="block text-sm text-gray-600">
|
||||
You can choose to exclude and ignore coding stats from unknown projects. Changing this setting will require to recompute your statistics.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-col w-full md:w-2/3 inline-block space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex flex-col gap-y-1">
|
||||
<label class="font-semibold text-gray-300" for="share_projects">Exclude unknown projects</label>
|
||||
<select autocomplete="off" id="unknown-projects-toggle" name="exclude_unknown_projects" class="select-default wi-min">
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ExcludeUnknownProjects }} selected {{ end }}>No
|
||||
</option>
|
||||
<option value="true" class="cursor-pointer" {{ if .User.ExcludeUnknownProjects }} selected {{ end }}>Yes
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary h-min">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="w-full">
|
||||
<hr class="border-t border-gray-800 my-4">
|
||||
</div>
|
||||
|
||||
<!-- Aliases -->
|
||||
<div class="w-full">
|
||||
<div class="flex flex-wrap flex-nowrap mb-8 gap-x-4">
|
||||
@@ -276,7 +309,6 @@
|
||||
<hr class="border-t border-gray-800 my-4">
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Project Labels -->
|
||||
<div class="w-full">
|
||||
<div class="flex flex-wrap md:flex-nowrap mb-8 gap-x-4">
|
||||
|
||||
Reference in New Issue
Block a user