From d8e6c9912594dfa03d55a021d538b6b0bd0561ee Mon Sep 17 00:00:00 2001
From: techknowlogick <techknowlogick@gitea.io>
Date: Wed, 17 Aug 2022 19:25:25 -0400
Subject: [PATCH] Add badge capabilities to users (#20607)

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: zeripath <art27@cantab.net>
Co-authored-by: John Olheiser <john.olheiser@gmail.com>
---
 .golangci.yml                   |  4 ++++
 models/migrations/migrations.go |  3 +++
 models/migrations/v999.go       | 28 ++++++++++++++++++++++
 models/user.go                  |  1 +
 models/user/badge.go            | 42 +++++++++++++++++++++++++++++++++
 routers/web/user/profile.go     |  7 ++++++
 templates/user/profile.tmpl     | 11 +++++++++
 web_src/less/_user.less         |  9 +++++++
 8 files changed, 105 insertions(+)
 create mode 100644 models/migrations/v999.go
 create mode 100644 models/user/badge.go

diff --git a/.golangci.yml b/.golangci.yml
index 6e80af8c0e..982ab06f0b 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -171,3 +171,7 @@ issues:
     - path: models/user/openid.go
       linters:
         - golint
+    - path: models/user/badge.go
+      linters:
+        - revive
+      text: "exported: type name will be used as user.UserBadge by other packages, and that stutters; consider calling this Badge"
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index 2719f45efb..aa08f055d3 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -406,6 +406,9 @@ var migrations = []Migration{
 	NewMigration("Drop old CredentialID column", dropOldCredentialIDColumn),
 	// v223 -> v224
 	NewMigration("Rename CredentialIDBytes column to CredentialID", renameCredentialIDBytes),
+
+	// v999
+	NewMigration("Add badges to users", creatUserBadgesTable),
 }
 
 // GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v999.go b/models/migrations/v999.go
new file mode 100644
index 0000000000..d684d538df
--- /dev/null
+++ b/models/migrations/v999.go
@@ -0,0 +1,28 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package migrations
+
+import (
+	"xorm.io/xorm"
+)
+
+func creatUserBadgesTable(x *xorm.Engine) error {
+	type Badge struct {
+		ID          int64 `xorm:"pk autoincr"`
+		Description string
+		ImageURL    string
+	}
+
+	type userBadge struct {
+		ID      int64 `xorm:"pk autoincr"`
+		BadgeID int64
+		UserID  int64 `xorm:"INDEX"`
+	}
+
+	if err := x.Sync2(new(Badge)); err != nil {
+		return err
+	}
+	return x.Sync2(new(userBadge))
+}
diff --git a/models/user.go b/models/user.go
index 86a714e746..4afbb9bea5 100644
--- a/models/user.go
+++ b/models/user.go
@@ -85,6 +85,7 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) (err error)
 		&organization.TeamUser{UID: u.ID},
 		&issues_model.Stopwatch{UserID: u.ID},
 		&user_model.Setting{UserID: u.ID},
+		&user_model.UserBadge{UserID: u.ID},
 		&pull_model.AutoMerge{DoerID: u.ID},
 		&pull_model.ReviewState{UserID: u.ID},
 	); err != nil {
diff --git a/models/user/badge.go b/models/user/badge.go
new file mode 100644
index 0000000000..5ff840cb8c
--- /dev/null
+++ b/models/user/badge.go
@@ -0,0 +1,42 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package user
+
+import (
+	"context"
+
+	"code.gitea.io/gitea/models/db"
+)
+
+// Badge represents a user badge
+type Badge struct {
+	ID          int64 `xorm:"pk autoincr"`
+	Description string
+	ImageURL    string
+}
+
+// UserBadge represents a user badge
+type UserBadge struct {
+	ID      int64 `xorm:"pk autoincr"`
+	BadgeID int64
+	UserID  int64 `xorm:"INDEX"`
+}
+
+func init() {
+	db.RegisterModel(new(Badge))
+	db.RegisterModel(new(UserBadge))
+}
+
+// GetUserBadges returns the user's badges.
+func GetUserBadges(ctx context.Context, u *User) ([]*Badge, int64, error) {
+	sess := db.GetEngine(ctx).
+		Select("`badge`.*").
+		Join("INNER", "user_badge", "`user_badge`.badge_id=badge.id").
+		Where("user_badge.user_id=?", u.ID)
+
+	badges := make([]*Badge, 0, 8)
+	count, err := sess.FindAndCount(&badges)
+	return badges, count, err
+}
diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go
index 6f23d239e2..c804be3c5f 100644
--- a/routers/web/user/profile.go
+++ b/routers/web/user/profile.go
@@ -105,6 +105,13 @@ func Profile(ctx *context.Context) {
 	ctx.Data["Orgs"] = orgs
 	ctx.Data["HasOrgsVisible"] = organization.HasOrgsVisible(orgs, ctx.Doer)
 
+	badges, _, err := user_model.GetUserBadges(ctx, ctx.ContextUser)
+	if err != nil {
+		ctx.ServerError("GetUserBadges", err)
+		return
+	}
+	ctx.Data["Badges"] = badges
+
 	tab := ctx.FormString("tab")
 	ctx.Data["TabName"] = tab
 
diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl
index 947c8fcebe..2a973c2d5b 100644
--- a/templates/user/profile.tmpl
+++ b/templates/user/profile.tmpl
@@ -69,6 +69,17 @@
 								</ul>
 							</li>
 							{{end}}
+							{{if .Badges}}
+							<li>
+								<ul class="user-badges">
+								{{range .Badges}}
+									<li>
+										<img width="64" height="64" src="{{.ImageURL}}" alt="{{.Description}}" data-content="{{.Description}}" class="tooltip"/>
+									</li>
+								{{end}}
+								</ul>
+							</li>
+							{{end}}
 							{{if and .IsSigned (ne .SignedUserName .Owner.Name)}}
 							<li class="follow">
 								{{if $.IsFollowing}}
diff --git a/web_src/less/_user.less b/web_src/less/_user.less
index a9b6c02fb7..eb9e791d86 100644
--- a/web_src/less/_user.less
+++ b/web_src/less/_user.less
@@ -169,6 +169,15 @@
   }
 }
 
+.user-badges {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, 64px);
+  gap: 2px;
+}
+
+.user-badges img {
+  object-fit: contain;
+}
 #notification_div .tab.segment {
   overflow-x: auto;
 }