From a4ad2c5f04e3bca3cae31d74fa0c9e25aa24f278 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Sat, 6 Nov 2021 15:25:31 -0500 Subject: [PATCH] Update data model for personal projects Also: - Added a helper for fetching posts with appropriate visibility - Added personal projects to the project index --- local/resetdb.sh | 6 +- src/db/db.go | 6 + src/migration/migrationTemplate.txt | 1 - .../2021-11-06T033835Z_CleanUpProjects.go | 68 ++++ .../2021-11-06T033930Z_PersonalProjects.go | 81 +++++ src/models/project.go | 6 +- src/templates/src/project_index.html | 44 +-- src/website/blogs.go | 2 +- src/website/feed.go | 2 +- src/website/project_helper.go | 300 ++++++++++++++++-- src/website/projects.go | 134 +++----- src/website/threads_and_posts_helper.go | 8 +- src/website/user.go | 2 +- 13 files changed, 501 insertions(+), 159 deletions(-) create mode 100644 src/migration/migrations/2021-11-06T033835Z_CleanUpProjects.go create mode 100644 src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go diff --git a/local/resetdb.sh b/local/resetdb.sh index 7f1e740..1cc0399 100755 --- a/local/resetdb.sh +++ b/local/resetdb.sh @@ -9,8 +9,8 @@ set -euxo pipefail # TODO(opensource): We should adapt Asaf's seedfile command and then delete this. THIS_PATH=$(pwd) -#BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta' - BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta' +BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta' +# BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta' pushd $BETA_PATH docker-compose down -v @@ -19,4 +19,4 @@ pushd $BETA_PATH docker-compose exec postgres bash -c "psql -U postgres -c \"CREATE ROLE hmn CREATEDB LOGIN PASSWORD 'password';\"" popd -go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-10-23 +go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-09-06 diff --git a/src/db/db.go b/src/db/db.go index 32996e2..01c20c9 100644 --- a/src/db/db.go +++ b/src/db/db.go @@ -60,6 +60,12 @@ type ConnOrTx interface { Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) + + // Both raw database connections and transactions in pgx can begin/commit + // transactions. For database connections it does the obvious thing; for + // transactions it creates a "pseudo-nested transaction" but conceptually + // works the same. See the documentation of pgx.Tx.Begin. + Begin(ctx context.Context) (pgx.Tx, error) } var connInfo = pgtype.NewConnInfo() diff --git a/src/migration/migrationTemplate.txt b/src/migration/migrationTemplate.txt index 7656e2f..53b4e35 100644 --- a/src/migration/migrationTemplate.txt +++ b/src/migration/migrationTemplate.txt @@ -5,7 +5,6 @@ import ( "time" "git.handmade.network/hmn/hmn/src/migration/types" - "git.handmade.network/hmn/hmn/src/oops" "github.com/jackc/pgx/v4" ) diff --git a/src/migration/migrations/2021-11-06T033835Z_CleanUpProjects.go b/src/migration/migrations/2021-11-06T033835Z_CleanUpProjects.go new file mode 100644 index 0000000..5bc0a55 --- /dev/null +++ b/src/migration/migrations/2021-11-06T033835Z_CleanUpProjects.go @@ -0,0 +1,68 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +func init() { + registerMigration(CleanUpProjects{}) +} + +type CleanUpProjects struct{} + +func (m CleanUpProjects) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 11, 6, 3, 38, 35, 0, time.UTC)) +} + +func (m CleanUpProjects) Name() string { + return "CleanUpProjects" +} + +func (m CleanUpProjects) Description() string { + return "Clean up projects with data violating our new constraints" +} + +func (m CleanUpProjects) Up(ctx context.Context, tx pgx.Tx) error { + var err error + + _, err = tx.Exec(ctx, ` + DELETE FROM handmade_project WHERE id IN (91, 92); + DELETE FROM handmade_communicationchoicelist + WHERE project_id IN (91, 92); + DELETE FROM handmade_project_languages + WHERE project_id IN (91, 92); + DELETE FROM handmade_project_screenshots + WHERE project_id IN (91, 92); + + UPDATE handmade_project + SET slug = 'hmh-notes' + WHERE slug = 'hmh_notes'; + `) + if err != nil { + return oops.New(err, "failed to patch up project slugs") + } + + return nil +} + +func (m CleanUpProjects) Down(ctx context.Context, tx pgx.Tx) error { + var err error + + _, err = tx.Exec(ctx, ` + -- Don't bother restoring those old projects + + UPDATE handmade_project + SET slug = 'hmh_notes' + WHERE slug = 'hmh-notes'; + `) + if err != nil { + return oops.New(err, "failed to restore project slugs") + } + + return nil +} diff --git a/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go b/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go new file mode 100644 index 0000000..b07b286 --- /dev/null +++ b/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go @@ -0,0 +1,81 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +func init() { + registerMigration(PersonalProjects{}) +} + +type PersonalProjects struct{} + +func (m PersonalProjects) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 11, 6, 3, 39, 30, 0, time.UTC)) +} + +func (m PersonalProjects) Name() string { + return "PersonalProjects" +} + +func (m PersonalProjects) Description() string { + return "Add data model for personal projects / tags" +} + +func (m PersonalProjects) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE handmade_project + DROP featurevotes, + DROP parent_id, + DROP quota, + DROP quota_used, + DROP standalone, + ALTER flags TYPE BOOLEAN USING flags > 0; + + ALTER TABLE handmade_project RENAME flags TO hidden; + `) + if err != nil { + return oops.New(err, "failed to clean up existing fields") + } + + _, err = tx.Exec(ctx, ` + ALTER TABLE handmade_project + ADD personal BOOLEAN NOT NULL DEFAULT TRUE, + ADD tag VARCHAR(20) NOT NULL DEFAULT ''; + `) + if err != nil { + return oops.New(err, "failed to add new fields") + } + + _, err = tx.Exec(ctx, ` + ALTER TABLE handmade_project + ADD CONSTRAINT slug_syntax CHECK ( + slug ~ '^([a-z0-9]+(-[a-z0-9]+)*)?$' + ), + ADD CONSTRAINT tag_syntax CHECK ( + tag ~ '^([a-z0-9]+(-[a-z0-9]+)*)?$' + ) + `) + if err != nil { + return oops.New(err, "failed to add check constraints") + } + + _, err = tx.Exec(ctx, ` + UPDATE handmade_project + SET personal = FALSE; + `) + if err != nil { + return oops.New(err, "failed to make existing projects official") + } + + return nil +} + +func (m PersonalProjects) Down(ctx context.Context, tx pgx.Tx) error { + panic("Implement me") +} diff --git a/src/models/project.go b/src/models/project.go index 5900312..9aee904 100644 --- a/src/models/project.go +++ b/src/models/project.go @@ -15,7 +15,7 @@ var ProjectType = reflect.TypeOf(Project{}) type ProjectLifecycle int const ( - ProjectLifecycleUnapproved = iota + ProjectLifecycleUnapproved ProjectLifecycle = iota ProjectLifecycleApprovalRequired ProjectLifecycleActive ProjectLifecycleHiatus @@ -41,6 +41,7 @@ type Project struct { Slug string `db:"slug"` Name string `db:"name"` + Tag string `db:"tag"` Blurb string `db:"blurb"` Description string `db:"description"` ParsedDescription string `db:"descparsed"` @@ -53,7 +54,8 @@ type Project struct { LogoLight string `db:"logolight"` LogoDark string `db:"logodark"` - Flags int `db:"flags"` // NOTE(asaf): Flags is currently only used to mark a project as hidden. Flags == 1 means hidden. Flags == 0 means visible. + Personal bool `db:"personal"` + Hidden bool `db:"hidden"` Featured bool `db:"featured"` DateApproved time.Time `db:"date_approved"` AllLastUpdated time.Time `db:"all_last_updated"` diff --git a/src/templates/src/project_index.html b/src/templates/src/project_index.html index 69d7f02..5962f0e 100644 --- a/src/templates/src/project_index.html +++ b/src/templates/src/project_index.html @@ -47,8 +47,12 @@
- 4 RSS Feed – New Projects - {{ template "pagination.html" .Pagination }} + +
+ {{ template "pagination.html" .Pagination }} +
@@ -66,38 +70,10 @@
- {{ if not .UserPendingProject }} -
-

Project submissions are closed

-

- We are reworking the way we approach projects on the network. In the meantime feel free to share your work on the forums or on our Discord. -

-
- {{ else }} -
-

Project pending

-

Thanks for considering us as a home for
{{ .UserPendingProject.Name }}!

-
- {{ if .UserPendingProjectUnderReview }} -

We see it's ready for review by an administrator, great! We'll try and get back to you in a timely manner.

- {{ else }} -

When you're ready for us to review it, let us know using the checkbox on {{ .UserPendingProject.Name }}'s profile editor.

- {{ end }} -
- {{ end }} - {{ if .UserApprovedProjects }} -
- {{ if .UserPendingProject }} -

Your other projects

- {{ else }} -

Your projects

- {{ end }} - {{ range .UserApprovedProjects }} -
- {{ template "project_card.html" projectcarddata . "" }} -
- {{ end }} -
+

Personal Projects

+

Many community members have projects of their own that are currently works in progress. Here's a few:

+ {{ range .PersonalProjects }} +
{{ .Name }}
{{ end }}
diff --git a/src/website/blogs.go b/src/website/blogs.go index e535fd7..9e31868 100644 --- a/src/website/blogs.go +++ b/src/website/blogs.go @@ -75,7 +75,7 @@ func BlogIndex(c *RequestContext) ResponseData { canCreate := false if c.CurrentUser != nil { isProjectOwner := false - owners, err := FetchProjectOwners(c, c.CurrentProject.ID) + owners, err := FetchProjectOwners(c.Context(), c.Conn, c.CurrentProject.ID) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project owners")) } diff --git a/src/website/feed.go b/src/website/feed.go index bb74862..5d6f166 100644 --- a/src/website/feed.go +++ b/src/website/feed.go @@ -167,7 +167,7 @@ func AtomFeed(c *RequestContext) ResponseData { handmade_project AS project WHERE project.lifecycle = ANY($1) - AND project.flags = 0 + AND NOT project.hidden ORDER BY date_approved DESC LIMIT $2 `, diff --git a/src/website/project_helper.go b/src/website/project_helper.go index 83ba743..f5fed7a 100644 --- a/src/website/project_helper.go +++ b/src/website/project_helper.go @@ -1,17 +1,173 @@ package website import ( + "context" + "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" ) +type ProjectTypeQuery int + +const ( + PersonalProjects ProjectTypeQuery = 1 << iota + OfficialProjects +) + +type ProjectsQuery struct { + Lifecycles []models.ProjectLifecycle + Types ProjectTypeQuery // bitfield + + // Ignored when using CountProjects + Limit, Offset int // if empty, no pagination +} + +type ProjectAndStuff struct { + Project models.Project + Owners []*models.User +} + +func FetchProjects( + ctx context.Context, + dbConn db.ConnOrTx, + currentUser *models.User, + q ProjectsQuery, +) ([]ProjectAndStuff, error) { + perf := ExtractPerf(ctx) + perf.StartBlock("SQL", "Fetch projects") + defer perf.EndBlock() + + var currentUserID *int + if currentUser != nil { + currentUserID = ¤tUser.ID + } + + tx, err := dbConn.Begin(ctx) + if err != nil { + return nil, oops.New(err, "failed to start transaction") + } + defer tx.Rollback(ctx) + + // Fetch all valid projects (not yet subject to user permission checks) + var qb db.QueryBuilder + qb.Add(` + SELECT $columns + FROM + handmade_project AS project + WHERE + NOT hidden + `) + if len(q.Lifecycles) > 0 { + qb.Add(`AND project.lifecycle = ANY($?)`, q.Lifecycles) + } else { + qb.Add(`AND project.lifecycle = ANY($?)`, models.VisibleProjectLifecycles) + } + if q.Types != 0 { + qb.Add(`AND (FALSE`) + if q.Types&PersonalProjects != 0 { + qb.Add(`OR project.personal`) + } + if q.Types&OfficialProjects != 0 { + qb.Add(`OR NOT project.personal`) + } + qb.Add(`)`) + } + if q.Limit > 0 { + qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset) + } + itProjects, err := db.Query(ctx, dbConn, models.Project{}, qb.String(), qb.Args()...) + if err != nil { + return nil, oops.New(err, "failed to fetch projects") + } + iprojects := itProjects.ToSlice() + + // Fetch project owners to do permission checks + projectIds := make([]int, len(iprojects)) + for i, iproject := range iprojects { + projectIds[i] = iproject.(*models.Project).ID + } + projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds) + if err != nil { + return nil, err + } + + var res []ProjectAndStuff + for i, iproject := range iprojects { + project := iproject.(*models.Project) + owners := projectOwners[i].Owners + + /* + Per our spec, a user can see a project if: + - The project is official + - The project is personal and all of the project's owners are approved + - The project is personal and the current user is a collaborator (regardless of user status) + + See https://www.notion.so/handmade-network/Technical-Plan-a11aaa9ea2f14d9a95f7d7780edd789c + */ + + var projectVisible bool + if project.Personal { + allOwnersApproved := true + for _, owner := range owners { + if owner.Status != models.UserStatusApproved { + allOwnersApproved = false + } + if currentUserID != nil && *currentUserID == owner.ID { + projectVisible = true + } + } + if allOwnersApproved { + projectVisible = true + } + } else { + projectVisible = true + } + + if projectVisible { + res = append(res, ProjectAndStuff{ + Project: *project, + Owners: owners, + }) + } + } + + err = tx.Commit(ctx) + if err != nil { + return nil, oops.New(err, "failed to commit transaction") + } + + return res, nil +} + +func CountProjects( + ctx context.Context, + dbConn db.ConnOrTx, + currentUser *models.User, + q ProjectsQuery, +) (int, error) { + perf := ExtractPerf(ctx) + perf.StartBlock("SQL", "Count projects") + defer perf.EndBlock() + + q.Limit = 0 + q.Offset = 0 + + // I'm lazy and there probably won't ever be that many projects. + projects, err := FetchProjects(ctx, dbConn, currentUser, q) + if err != nil { + return 0, oops.New(err, "failed to fetch projects") + } + + return len(projects), nil +} + func CanEditProject(c *RequestContext, user *models.User, projectId int) (bool, error) { if user != nil { if user.IsStaff { return true, nil } else { - owners, err := FetchProjectOwners(c, projectId) + owners, err := FetchProjectOwners(c.Context(), c.Conn, projectId) if err != nil { return false, err } @@ -25,29 +181,137 @@ func CanEditProject(c *RequestContext, user *models.User, projectId int) (bool, return false, nil } -func FetchProjectOwners(c *RequestContext, projectId int) ([]*models.User, error) { - var result []*models.User - c.Perf.StartBlock("SQL", "Fetching project owners") - type ownerQuery struct { - Owner models.User `db:"auth_user"` +type ProjectOwners struct { + ProjectID int + Owners []*models.User +} + +// Fetches all owners for multiple projects. Does NOT check permissions on the +// project IDs, since the assumption is that you will check permissions on the +// projects themselves before using any of this data. +// +// The returned slice will always have one entry for each project ID given, in +// the same order as they were provided. If there are duplicate project IDs in +// projectIds, the results will be wrong, so don't do that. +// +// This function does not verify that the requested projects do in fact exist. +func FetchMultipleProjectsOwners( + ctx context.Context, + dbConn db.ConnOrTx, + projectIds []int, +) ([]ProjectOwners, error) { + perf := ExtractPerf(ctx) + perf.StartBlock("SQL", "Fetch owners for multiple projects") + defer perf.EndBlock() + + tx, err := dbConn.Begin(ctx) + if err != nil { + return nil, oops.New(err, "failed to start transaction") } - ownerQueryResult, err := db.Query(c.Context(), c.Conn, ownerQuery{}, + defer tx.Rollback(ctx) + + // Fetch all user/project pairs for the given projects + type userProject struct { + UserID int `db:"user_id"` + ProjectID int `db:"project_id"` + } + it, err := db.Query(ctx, tx, userProject{}, ` SELECT $columns - FROM - auth_user - INNER JOIN handmade_user_projects AS uproj ON uproj.user_id = auth_user.id - WHERE - uproj.project_id = $1 + FROM handmade_user_projects + WHERE project_id = ANY($1) `, - projectId, + projectIds, ) - c.Perf.EndBlock() if err != nil { - return result, oops.New(err, "failed to fetch owners for project") + return nil, oops.New(err, "failed to fetch project IDs") } - for _, ownerRow := range ownerQueryResult.ToSlice() { - result = append(result, &ownerRow.(*ownerQuery).Owner) + iuserprojects := it.ToSlice() + + // Get the unique user IDs from this set and fetch the users from the db + var userIds []int + for _, iuserproject := range iuserprojects { + userProject := iuserproject.(*userProject) + + addUserId := true + for _, uid := range userIds { + if uid == userProject.UserID { + addUserId = false + } + } + if addUserId { + userIds = append(userIds, userProject.UserID) + } } - return result, nil + it, err = db.Query(ctx, tx, models.User{}, + ` + SELECT $columns + FROM auth_user + WHERE + id = ANY($1) + `, + userIds, + ) + if err != nil { + return nil, oops.New(err, "failed to fetch users for projects") + } + iusers := it.ToSlice() + + // Build the final result set with real user data + res := make([]ProjectOwners, len(projectIds)) + for i, pid := range projectIds { + res[i] = ProjectOwners{ProjectID: pid} + } + for _, iuserproject := range iuserprojects { + userProject := iuserproject.(*userProject) + + // Get a pointer to the existing record in the result + var projectOwners *ProjectOwners + for i := range res { + if res[i].ProjectID == userProject.ProjectID { + projectOwners = &res[i] + } + } + + // Get the full user record we fetched + var user *models.User + for _, iuser := range iusers { + u := iuser.(*models.User) + if u.ID == userProject.UserID { + user = u + } + } + if user == nil { + panic("we apparently failed to fetch a project's owner") + } + + // Slam 'em together + projectOwners.Owners = append(projectOwners.Owners, user) + } + + err = tx.Commit(ctx) + if err != nil { + return nil, oops.New(err, "failed to commit transaction") + } + + return res, nil +} + +// Fetches project owners for a single project. It is subject to all the same +// restrictions as FetchMultipleProjectsOwners. +func FetchProjectOwners( + ctx context.Context, + dbConn db.ConnOrTx, + projectId int, +) ([]*models.User, error) { + perf := ExtractPerf(ctx) + perf.StartBlock("SQL", "Fetch owners for project") + defer perf.EndBlock() + + projectOwners, err := FetchMultipleProjectsOwners(ctx, dbConn, []int{projectId}) + if err != nil { + return nil, err + } + + return projectOwners[0].Owners, nil } diff --git a/src/website/projects.go b/src/website/projects.go index 7daf700..8f533f2 100644 --- a/src/website/projects.go +++ b/src/website/projects.go @@ -6,7 +6,6 @@ import ( "math" "math/rand" "net/http" - "strconv" "strings" "time" @@ -24,10 +23,7 @@ type ProjectTemplateData struct { Pagination templates.Pagination CarouselProjects []templates.Project Projects []templates.Project - - UserPendingProjectUnderReview bool - UserPendingProject *templates.Project - UserApprovedProjects []templates.Project + PersonalProjects []templates.Project ProjectAtomFeedUrl string WIPForumUrl string @@ -36,47 +32,19 @@ type ProjectTemplateData struct { func ProjectIndex(c *RequestContext) ResponseData { const projectsPerPage = 20 const maxCarouselProjects = 10 + const maxPersonalProjects = 10 - page := 1 - pageString, hasPage := c.PathParams["page"] - if hasPage && pageString != "" { - if pageParsed, err := strconv.Atoi(pageString); err == nil { - page = pageParsed - } else { - return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther) - } - } - - if page < 1 { - return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther) - } - - c.Perf.StartBlock("SQL", "Fetching all visible projects") - type projectResult struct { - Project models.Project `db:"project"` - } - allProjects, err := db.Query(c.Context(), c.Conn, projectResult{}, - ` - SELECT $columns - FROM - handmade_project AS project - WHERE - project.lifecycle = ANY($1) - AND project.flags = 0 - ORDER BY project.date_approved ASC - `, - models.VisibleProjectLifecycles, - ) + officialProjects, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, ProjectsQuery{ + Types: OfficialProjects, + }) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects")) } - allProjectsSlice := allProjects.ToSlice() - c.Perf.EndBlock() - numPages := int(math.Ceil(float64(len(allProjectsSlice)) / projectsPerPage)) - - if page > numPages { - return c.Redirect(hmnurl.BuildProjectIndex(numPages), http.StatusSeeOther) + numPages := int(math.Ceil(float64(len(officialProjects)) / projectsPerPage)) + page, numPages, ok := getPageInfo(c.PathParams["page"], len(officialProjects), feedPostsPerPage) + if !ok { + return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther) } pagination := templates.Pagination{ @@ -89,63 +57,22 @@ func ProjectIndex(c *RequestContext) ResponseData { PreviousUrl: hmnurl.BuildProjectIndex(utils.IntClamp(1, page-1, numPages)), } - var userApprovedProjects []templates.Project - var userPendingProject *templates.Project - userPendingProjectUnderReview := false - if c.CurrentUser != nil { - c.Perf.StartBlock("SQL", "fetching user projects") - type UserProjectQuery struct { - Project models.Project `db:"project"` - } - userProjectsResult, err := db.Query(c.Context(), c.Conn, UserProjectQuery{}, - ` - SELECT $columns - FROM - handmade_project AS project - INNER JOIN handmade_user_projects AS uproj ON uproj.project_id = project.id - WHERE - uproj.user_id = $1 - `, - c.CurrentUser.ID, - ) - if err != nil { - return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user projects")) - } - for _, project := range userProjectsResult.ToSlice() { - p := project.(*UserProjectQuery).Project - if p.Lifecycle == models.ProjectLifecycleUnapproved || p.Lifecycle == models.ProjectLifecycleApprovalRequired { - if userPendingProject == nil { - // NOTE(asaf): Technically a user could have more than one pending project. - // For example, if they created one project themselves and were added as an additional owner to another user's project. - // So we'll just take the first one. I don't think it matters. I guess it especially won't matter after Projects 2.0. - tmplProject := templates.ProjectToTemplate(&p, c.Theme) - userPendingProject = &tmplProject - userPendingProjectUnderReview = (p.Lifecycle == models.ProjectLifecycleApprovalRequired) - } - } else { - userApprovedProjects = append(userApprovedProjects, templates.ProjectToTemplate(&p, c.Theme)) - } - } - c.Perf.EndBlock() - } - c.Perf.StartBlock("PROJECTS", "Grouping and sorting") var handmadeHero *templates.Project var featuredProjects []templates.Project var recentProjects []templates.Project var restProjects []templates.Project now := time.Now() - for _, p := range allProjectsSlice { - project := &p.(*projectResult).Project - templateProject := templates.ProjectToTemplate(project, c.Theme) - if project.Slug == "hero" { + for _, p := range officialProjects { + templateProject := templates.ProjectToTemplate(&p.Project, c.Theme) + if p.Project.Slug == "hero" { // NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list. handmadeHero = &templateProject continue } - if project.Featured { + if p.Project.Featured { featuredProjects = append(featuredProjects, templateProject) - } else if now.Sub(project.AllLastUpdated).Seconds() < models.RecentProjectUpdateTimespanSec { + } else if now.Sub(p.Project.AllLastUpdated).Seconds() < models.RecentProjectUpdateTimespanSec { recentProjects = append(recentProjects, templateProject) } else { restProjects = append(restProjects, templateProject) @@ -178,6 +105,28 @@ func ProjectIndex(c *RequestContext) ResponseData { } c.Perf.EndBlock() + // Fetch and highlight a random selection of personal projects + var personalProjects []templates.Project + { + projects, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, ProjectsQuery{ + Types: PersonalProjects, + }) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch personal projects")) + } + + randSeed := now.YearDay() + random := rand.New(rand.NewSource(int64(randSeed))) + random.Shuffle(len(projects), func(i, j int) { projects[i], projects[j] = projects[j], projects[i] }) + + for i, p := range projects { + if i >= maxPersonalProjects { + break + } + personalProjects = append(personalProjects, templates.ProjectToTemplate(&p.Project, c.Theme)) + } + } + baseData := getBaseDataAutocrumb(c, "Projects") var res ResponseData res.MustWriteTemplate("project_index.html", ProjectTemplateData{ @@ -186,10 +135,7 @@ func ProjectIndex(c *RequestContext) ResponseData { Pagination: pagination, CarouselProjects: carouselProjects, Projects: pageProjects, - - UserPendingProjectUnderReview: userPendingProjectUnderReview, - UserPendingProject: userPendingProject, - UserApprovedProjects: userApprovedProjects, + PersonalProjects: personalProjects, ProjectAtomFeedUrl: hmnurl.BuildAtomFeedForProjects(), WIPForumUrl: hmnurl.BuildForum(models.HMNProjectSlug, []string{"wip"}, 1), @@ -253,7 +199,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { return FourOhFour(c) } - owners, err := FetchProjectOwners(c, project.ID) + owners, err := FetchProjectOwners(c.Context(), c.Conn, project.ID) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, err) } @@ -275,7 +221,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { } } if !canView { - if project.Flags == 0 { + if !project.Hidden { for _, lc := range models.VisibleProjectLifecycles { if project.Lifecycle == lc { canView = true @@ -377,7 +323,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme)) } - if project.Flags == 1 { + if project.Hidden { projectHomepageData.BaseData.AddImmediateNotice( "hidden", "NOTICE: This project is hidden. It is currently visible only to owners and site admins.", diff --git a/src/website/threads_and_posts_helper.go b/src/website/threads_and_posts_helper.go index b1d32ed..9cac64b 100644 --- a/src/website/threads_and_posts_helper.go +++ b/src/website/threads_and_posts_helper.go @@ -88,7 +88,7 @@ func FetchThreads( WHERE NOT thread.deleted AND ( -- project has valid lifecycle - project.flags = 0 AND project.lifecycle = ANY($?) + NOT project.hidden AND project.lifecycle = ANY($?) OR project.id = $? ) `, @@ -219,7 +219,7 @@ func CountThreads( WHERE NOT thread.deleted AND ( -- project has valid lifecycle - project.flags = 0 AND project.lifecycle = ANY($?) + NOT project.hidden AND project.lifecycle = ANY($?) OR project.id = $? ) `, @@ -343,7 +343,7 @@ func FetchPosts( NOT thread.deleted AND NOT post.deleted AND ( -- project has valid lifecycle - project.flags = 0 AND project.lifecycle = ANY($?) + NOT project.hidden AND project.lifecycle = ANY($?) OR project.id = $? ) `, @@ -543,7 +543,7 @@ func CountPosts( NOT thread.deleted AND NOT post.deleted AND ( -- project has valid lifecycle - project.flags = 0 AND project.lifecycle = ANY($?) + NOT project.hidden AND project.lifecycle = ANY($?) OR project.id = $? ) `, diff --git a/src/website/user.go b/src/website/user.go index 92fba61..e1f79b7 100644 --- a/src/website/user.go +++ b/src/website/user.go @@ -108,7 +108,7 @@ func UserProfile(c *RequestContext) ResponseData { INNER JOIN handmade_user_projects AS uproj ON uproj.project_id = project.id WHERE uproj.user_id = $1 - AND ($2 OR (project.flags = 0 AND project.lifecycle = ANY ($3))) + AND ($2 OR (NOT project.hidden AND project.lifecycle = ANY ($3))) `, profileUser.ID, (c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsStaff)),