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:
parent
b53643764a
commit
3800d3e715
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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"`
|
||||
|
|
|
@ -47,9 +47,13 @@
|
|||
<div class="bg--dim-ns br2">
|
||||
<div class="clear"></div>
|
||||
<div class="optionbar pv2 ph3">
|
||||
<div class="options">
|
||||
<a href="{{ .ProjectAtomFeedUrl }}"><span class="icon big">4</span> RSS Feed – New Projects</span></a>
|
||||
</div>
|
||||
<div class="options">
|
||||
{{ template "pagination.html" .Pagination }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="projectlist ph3">
|
||||
{{ range .Projects }}
|
||||
|
@ -66,38 +70,10 @@
|
|||
</div>
|
||||
<div class="w-100 w-40-l ph3 ph0-ns flex-shrink-0">
|
||||
<div class="ml3-l mt3 mt0-l pa3 bg--dim br2">
|
||||
{{ if not .UserPendingProject }}
|
||||
<div class="content-block new-project p-spaced">
|
||||
<h2>Project submissions are closed</h2>
|
||||
<p>
|
||||
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>
|
||||
<h2>Personal Projects</h2>
|
||||
<p>Many community members have projects of their own that are currently works in progress. Here's a few:</p>
|
||||
{{ range .PersonalProjects }}
|
||||
<div><a href="{{ .Url }}">{{ .Name }}</a></div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
@ -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
|
||||
`,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
ownerQueryResult, err := db.Query(c.Context(), c.Conn, ownerQuery{},
|
||||
|
||||
// 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")
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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 = $?
|
||||
)
|
||||
`,
|
||||
|
|
|
@ -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)),
|
||||
|
|
Loading…
Reference in New Issue