Update data model for personal projects

Also:
- Added a helper for fetching posts with appropriate visibility
- Added personal projects to the project index
This commit is contained in:
Ben Visness 2021-11-06 15:25:31 -05:00
parent 1d8e12a4f6
commit a4ad2c5f04
13 changed files with 501 additions and 159 deletions

View File

@ -9,8 +9,8 @@ set -euxo pipefail
# TODO(opensource): We should adapt Asaf's seedfile command and then delete this. # TODO(opensource): We should adapt Asaf's seedfile command and then delete this.
THIS_PATH=$(pwd) THIS_PATH=$(pwd)
#BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta' BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta'
BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta' # BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta'
pushd $BETA_PATH pushd $BETA_PATH
docker-compose down -v 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';\"" docker-compose exec postgres bash -c "psql -U postgres -c \"CREATE ROLE hmn CREATEDB LOGIN PASSWORD 'password';\""
popd 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

View File

@ -60,6 +60,12 @@ type ConnOrTx interface {
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row
Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) 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() var connInfo = pgtype.NewConnInfo()

View File

@ -5,7 +5,6 @@ import (
"time" "time"
"git.handmade.network/hmn/hmn/src/migration/types" "git.handmade.network/hmn/hmn/src/migration/types"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
) )

View File

@ -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
}

View File

@ -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")
}

View File

@ -15,7 +15,7 @@ var ProjectType = reflect.TypeOf(Project{})
type ProjectLifecycle int type ProjectLifecycle int
const ( const (
ProjectLifecycleUnapproved = iota ProjectLifecycleUnapproved ProjectLifecycle = iota
ProjectLifecycleApprovalRequired ProjectLifecycleApprovalRequired
ProjectLifecycleActive ProjectLifecycleActive
ProjectLifecycleHiatus ProjectLifecycleHiatus
@ -41,6 +41,7 @@ type Project struct {
Slug string `db:"slug"` Slug string `db:"slug"`
Name string `db:"name"` Name string `db:"name"`
Tag string `db:"tag"`
Blurb string `db:"blurb"` Blurb string `db:"blurb"`
Description string `db:"description"` Description string `db:"description"`
ParsedDescription string `db:"descparsed"` ParsedDescription string `db:"descparsed"`
@ -53,7 +54,8 @@ type Project struct {
LogoLight string `db:"logolight"` LogoLight string `db:"logolight"`
LogoDark string `db:"logodark"` 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"` Featured bool `db:"featured"`
DateApproved time.Time `db:"date_approved"` DateApproved time.Time `db:"date_approved"`
AllLastUpdated time.Time `db:"all_last_updated"` AllLastUpdated time.Time `db:"all_last_updated"`

View File

@ -47,9 +47,13 @@
<div class="bg--dim-ns br2"> <div class="bg--dim-ns br2">
<div class="clear"></div> <div class="clear"></div>
<div class="optionbar pv2 ph3"> <div class="optionbar pv2 ph3">
<div class="options">
<a href="{{ .ProjectAtomFeedUrl }}"><span class="icon big">4</span> RSS Feed &ndash; New Projects</span></a> <a href="{{ .ProjectAtomFeedUrl }}"><span class="icon big">4</span> RSS Feed &ndash; New Projects</span></a>
</div>
<div class="options">
{{ template "pagination.html" .Pagination }} {{ template "pagination.html" .Pagination }}
</div> </div>
</div>
<div class="projectlist ph3"> <div class="projectlist ph3">
{{ range .Projects }} {{ range .Projects }}
@ -66,38 +70,10 @@
</div> </div>
<div class="w-100 w-40-l ph3 ph0-ns flex-shrink-0"> <div class="w-100 w-40-l ph3 ph0-ns flex-shrink-0">
<div class="ml3-l mt3 mt0-l pa3 bg--dim br2"> <div class="ml3-l mt3 mt0-l pa3 bg--dim br2">
{{ if not .UserPendingProject }} <h2>Personal Projects</h2>
<div class="content-block new-project p-spaced"> <p>Many community members have projects of their own that are currently works in progress. Here's a few:</p>
<h2>Project submissions are closed</h2> {{ range .PersonalProjects }}
<p> <div><a href="{{ .Url }}">{{ .Name }}</a></div>
We are reworking the way we approach projects on the network. In the meantime feel free to share your work on the <a href="{{ .WIPForumUrl }}">forums</a> or on our <a href="https://discord.gg/hxWxDee">Discord</a>.
</p>
</div>
{{ else }}
<div class="content-block single">
<h2>Project pending</h2>
<p>Thanks for considering us as a home for<br /><a href="{{ .UserPendingProject.Url }}">{{ .UserPendingProject.Name }}</a>!</p>
<br />
{{ if .UserPendingProjectUnderReview }}
<p>We see it's ready for review by an administrator, great! We'll try and get back to you in a timely manner.</p>
{{ else }}
<p>When you're ready for us to review it, let us know using the checkbox on {{ .UserPendingProject.Name }}'s profile editor.</p>
{{ end }}
</div>
{{ end }}
{{ if .UserApprovedProjects }}
<div class="content-block single projectlist">
{{ if .UserPendingProject }}
<h2>Your other projects</h2>
{{ else }}
<h2>Your projects</h2>
{{ end }}
{{ range .UserApprovedProjects }}
<div class="mv3">
{{ template "project_card.html" projectcarddata . "" }}
</div>
{{ end }}
</div>
{{ end }} {{ end }}
</div> </div>
</div> </div>

View File

@ -75,7 +75,7 @@ func BlogIndex(c *RequestContext) ResponseData {
canCreate := false canCreate := false
if c.CurrentUser != nil { if c.CurrentUser != nil {
isProjectOwner := false isProjectOwner := false
owners, err := FetchProjectOwners(c, c.CurrentProject.ID) owners, err := FetchProjectOwners(c.Context(), c.Conn, c.CurrentProject.ID)
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project owners")) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project owners"))
} }

View File

@ -167,7 +167,7 @@ func AtomFeed(c *RequestContext) ResponseData {
handmade_project AS project handmade_project AS project
WHERE WHERE
project.lifecycle = ANY($1) project.lifecycle = ANY($1)
AND project.flags = 0 AND NOT project.hidden
ORDER BY date_approved DESC ORDER BY date_approved DESC
LIMIT $2 LIMIT $2
`, `,

View File

@ -1,17 +1,173 @@
package website package website
import ( import (
"context"
"git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops" "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 = &currentUser.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) { func CanEditProject(c *RequestContext, user *models.User, projectId int) (bool, error) {
if user != nil { if user != nil {
if user.IsStaff { if user.IsStaff {
return true, nil return true, nil
} else { } else {
owners, err := FetchProjectOwners(c, projectId) owners, err := FetchProjectOwners(c.Context(), c.Conn, projectId)
if err != nil { if err != nil {
return false, err return false, err
} }
@ -25,29 +181,137 @@ func CanEditProject(c *RequestContext, user *models.User, projectId int) (bool,
return false, nil return false, nil
} }
func FetchProjectOwners(c *RequestContext, projectId int) ([]*models.User, error) { type ProjectOwners struct {
var result []*models.User ProjectID int
c.Perf.StartBlock("SQL", "Fetching project owners") Owners []*models.User
type ownerQuery struct { }
Owner models.User `db:"auth_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 SELECT $columns
FROM FROM handmade_user_projects
auth_user WHERE project_id = ANY($1)
INNER JOIN handmade_user_projects AS uproj ON uproj.user_id = auth_user.id
WHERE
uproj.project_id = $1
`, `,
projectId, projectIds,
) )
c.Perf.EndBlock()
if err != nil { 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() { iuserprojects := it.ToSlice()
result = append(result, &ownerRow.(*ownerQuery).Owner)
// 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
} }
return result, nil }
if addUserId {
userIds = append(userIds, userProject.UserID)
}
}
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
} }

View File

@ -6,7 +6,6 @@ import (
"math" "math"
"math/rand" "math/rand"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@ -24,10 +23,7 @@ type ProjectTemplateData struct {
Pagination templates.Pagination Pagination templates.Pagination
CarouselProjects []templates.Project CarouselProjects []templates.Project
Projects []templates.Project Projects []templates.Project
PersonalProjects []templates.Project
UserPendingProjectUnderReview bool
UserPendingProject *templates.Project
UserApprovedProjects []templates.Project
ProjectAtomFeedUrl string ProjectAtomFeedUrl string
WIPForumUrl string WIPForumUrl string
@ -36,47 +32,19 @@ type ProjectTemplateData struct {
func ProjectIndex(c *RequestContext) ResponseData { func ProjectIndex(c *RequestContext) ResponseData {
const projectsPerPage = 20 const projectsPerPage = 20
const maxCarouselProjects = 10 const maxCarouselProjects = 10
const maxPersonalProjects = 10
page := 1 officialProjects, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, ProjectsQuery{
pageString, hasPage := c.PathParams["page"] Types: OfficialProjects,
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,
)
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects")) 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)) numPages := int(math.Ceil(float64(len(officialProjects)) / projectsPerPage))
page, numPages, ok := getPageInfo(c.PathParams["page"], len(officialProjects), feedPostsPerPage)
if page > numPages { if !ok {
return c.Redirect(hmnurl.BuildProjectIndex(numPages), http.StatusSeeOther) return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther)
} }
pagination := templates.Pagination{ pagination := templates.Pagination{
@ -89,63 +57,22 @@ func ProjectIndex(c *RequestContext) ResponseData {
PreviousUrl: hmnurl.BuildProjectIndex(utils.IntClamp(1, page-1, numPages)), 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") c.Perf.StartBlock("PROJECTS", "Grouping and sorting")
var handmadeHero *templates.Project var handmadeHero *templates.Project
var featuredProjects []templates.Project var featuredProjects []templates.Project
var recentProjects []templates.Project var recentProjects []templates.Project
var restProjects []templates.Project var restProjects []templates.Project
now := time.Now() now := time.Now()
for _, p := range allProjectsSlice { for _, p := range officialProjects {
project := &p.(*projectResult).Project templateProject := templates.ProjectToTemplate(&p.Project, c.Theme)
templateProject := templates.ProjectToTemplate(project, c.Theme) if p.Project.Slug == "hero" {
if project.Slug == "hero" {
// NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list. // NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list.
handmadeHero = &templateProject handmadeHero = &templateProject
continue continue
} }
if project.Featured { if p.Project.Featured {
featuredProjects = append(featuredProjects, templateProject) 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) recentProjects = append(recentProjects, templateProject)
} else { } else {
restProjects = append(restProjects, templateProject) restProjects = append(restProjects, templateProject)
@ -178,6 +105,28 @@ func ProjectIndex(c *RequestContext) ResponseData {
} }
c.Perf.EndBlock() 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") baseData := getBaseDataAutocrumb(c, "Projects")
var res ResponseData var res ResponseData
res.MustWriteTemplate("project_index.html", ProjectTemplateData{ res.MustWriteTemplate("project_index.html", ProjectTemplateData{
@ -186,10 +135,7 @@ func ProjectIndex(c *RequestContext) ResponseData {
Pagination: pagination, Pagination: pagination,
CarouselProjects: carouselProjects, CarouselProjects: carouselProjects,
Projects: pageProjects, Projects: pageProjects,
PersonalProjects: personalProjects,
UserPendingProjectUnderReview: userPendingProjectUnderReview,
UserPendingProject: userPendingProject,
UserApprovedProjects: userApprovedProjects,
ProjectAtomFeedUrl: hmnurl.BuildAtomFeedForProjects(), ProjectAtomFeedUrl: hmnurl.BuildAtomFeedForProjects(),
WIPForumUrl: hmnurl.BuildForum(models.HMNProjectSlug, []string{"wip"}, 1), WIPForumUrl: hmnurl.BuildForum(models.HMNProjectSlug, []string{"wip"}, 1),
@ -253,7 +199,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
return FourOhFour(c) return FourOhFour(c)
} }
owners, err := FetchProjectOwners(c, project.ID) owners, err := FetchProjectOwners(c.Context(), c.Conn, project.ID)
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err) return c.ErrorResponse(http.StatusInternalServerError, err)
} }
@ -275,7 +221,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
} }
} }
if !canView { if !canView {
if project.Flags == 0 { if !project.Hidden {
for _, lc := range models.VisibleProjectLifecycles { for _, lc := range models.VisibleProjectLifecycles {
if project.Lifecycle == lc { if project.Lifecycle == lc {
canView = true canView = true
@ -377,7 +323,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme)) projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme))
} }
if project.Flags == 1 { if project.Hidden {
projectHomepageData.BaseData.AddImmediateNotice( projectHomepageData.BaseData.AddImmediateNotice(
"hidden", "hidden",
"NOTICE: This project is hidden. It is currently visible only to owners and site admins.", "NOTICE: This project is hidden. It is currently visible only to owners and site admins.",

View File

@ -88,7 +88,7 @@ func FetchThreads(
WHERE WHERE
NOT thread.deleted NOT thread.deleted
AND ( -- project has valid lifecycle AND ( -- project has valid lifecycle
project.flags = 0 AND project.lifecycle = ANY($?) NOT project.hidden AND project.lifecycle = ANY($?)
OR project.id = $? OR project.id = $?
) )
`, `,
@ -219,7 +219,7 @@ func CountThreads(
WHERE WHERE
NOT thread.deleted NOT thread.deleted
AND ( -- project has valid lifecycle AND ( -- project has valid lifecycle
project.flags = 0 AND project.lifecycle = ANY($?) NOT project.hidden AND project.lifecycle = ANY($?)
OR project.id = $? OR project.id = $?
) )
`, `,
@ -343,7 +343,7 @@ func FetchPosts(
NOT thread.deleted NOT thread.deleted
AND NOT post.deleted AND NOT post.deleted
AND ( -- project has valid lifecycle AND ( -- project has valid lifecycle
project.flags = 0 AND project.lifecycle = ANY($?) NOT project.hidden AND project.lifecycle = ANY($?)
OR project.id = $? OR project.id = $?
) )
`, `,
@ -543,7 +543,7 @@ func CountPosts(
NOT thread.deleted NOT thread.deleted
AND NOT post.deleted AND NOT post.deleted
AND ( -- project has valid lifecycle AND ( -- project has valid lifecycle
project.flags = 0 AND project.lifecycle = ANY($?) NOT project.hidden AND project.lifecycle = ANY($?)
OR project.id = $? OR project.id = $?
) )
`, `,

View File

@ -108,7 +108,7 @@ func UserProfile(c *RequestContext) ResponseData {
INNER JOIN handmade_user_projects AS uproj ON uproj.project_id = project.id INNER JOIN handmade_user_projects AS uproj ON uproj.project_id = project.id
WHERE WHERE
uproj.user_id = $1 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, profileUser.ID,
(c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsStaff)), (c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsStaff)),