From 92b90013abe06dfa98213a6b575a2fb63bc28908 Mon Sep 17 00:00:00 2001 From: kolaente Date: Sun, 13 Oct 2024 19:22:09 +0200 Subject: [PATCH] feat(projects): optionally return max right when querying all projects This change introduces an expand query parameter which, when provided, allows to return all projects with the max right the current user has on that project. This allows to show and hide appropriate buttons in the frontend. Resolves https://github.com/go-vikunja/vikunja/issues/334 --- .../home/ProjectsNavigationItem.vue | 8 +- .../project/ProjectSettingsDropdown.vue | 2 + frontend/src/stores/projects.ts | 2 +- .../project/settings/ProjectSettingsEdit.vue | 3 +- pkg/models/project.go | 44 ++++++ pkg/models/project_rights.go | 139 ++++++++++-------- pkg/models/rights.go | 42 +++++- 7 files changed, 169 insertions(+), 71 deletions(-) diff --git a/frontend/src/components/home/ProjectsNavigationItem.vue b/frontend/src/components/home/ProjectsNavigationItem.vue index c69cf7202..68d0757cb 100644 --- a/frontend/src/components/home/ProjectsNavigationItem.vue +++ b/frontend/src/components/home/ProjectsNavigationItem.vue @@ -25,7 +25,7 @@ />
@@ -48,7 +48,7 @@ {{ getProjectTitle(project) }} @@ -94,6 +95,7 @@ import ProjectSettingsDropdown from '@/components/project/ProjectSettingsDropdow import {getProjectTitle} from '@/helpers/getProjectTitle' import ColorBubble from '@/components/misc/ColorBubble.vue' import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue' +import {RIGHTS} from '@/constants/rights' const props = defineProps<{ project: IProject, diff --git a/frontend/src/components/project/ProjectSettingsDropdown.vue b/frontend/src/components/project/ProjectSettingsDropdown.vue index 4ce76144f..c25e28932 100644 --- a/frontend/src/components/project/ProjectSettingsDropdown.vue +++ b/frontend/src/components/project/ProjectSettingsDropdown.vue @@ -109,6 +109,7 @@ {{ $t('menu.createProject') }} { let page = 1 try { do { - const newProjects = await projectService.getAll({}, {is_archived: true}, page) as IProject[] + const newProjects = await projectService.getAll({}, {is_archived: true, expand: 'rights'}, page) as IProject[] loadedProjects.push(...newProjects) page++ } while (page <= projectService.totalPages) diff --git a/frontend/src/views/project/settings/ProjectSettingsEdit.vue b/frontend/src/views/project/settings/ProjectSettingsEdit.vue index 6ff325656..4243ba2f9 100644 --- a/frontend/src/views/project/settings/ProjectSettingsEdit.vue +++ b/frontend/src/views/project/settings/ProjectSettingsEdit.vue @@ -3,7 +3,7 @@ :title="$t('project.edit.header')" primary-icon="" :primary-label="$t('misc.save')" - :tertiary="$t('misc.delete')" + :tertiary="project.maxRight === RIGHTS.ADMIN ? $t('misc.delete') : undefined" @primary="save" @tertiary="$router.push({ name: 'project.settings.delete', params: { id: projectId } })" > @@ -99,6 +99,7 @@ import {useProjectStore} from '@/stores/projects' import {useProject} from '@/stores/projects' import {useTitle} from '@/composables/useTitle' +import {RIGHTS} from '@/constants/rights' const props = defineProps<{ projectId: IProject['id'], diff --git a/pkg/models/project.go b/pkg/models/project.go index 1592d52c3..f729d1475 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -77,6 +77,9 @@ type Project struct { Views []*ProjectView `xorm:"-" json:"views"` + Expand ProjectExpandable `xorm:"-" json:"-" query:"expand"` + MaxRight Right `xorm:"-" json:"max_right"` + // A timestamp when this project was created. You cannot change this value. Created time.Time `xorm:"created not null" json:"created"` // A timestamp when this project was last updated. You cannot change this value. @@ -86,6 +89,10 @@ type Project struct { web.Rights `xorm:"-" json:"-"` } +type ProjectExpandable string + +const ProjectExpandableRights = `rights` + type ProjectWithTasksAndBuckets struct { Project ChildProjects []*ProjectWithTasksAndBuckets `xorm:"-" json:"child_projects"` @@ -161,6 +168,7 @@ var FavoritesPseudoProject = Project{ // @Param per_page query int false "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page." // @Param s query string false "Search projects by title." // @Param is_archived query bool false "If true, also returns all archived projects." +// @Param expand query string false "If set to `rights`, Vikunja will return the max right the current user has on this project. You can currently only set this to `rights`." // @Security JWTKeyAuth // @Success 200 {array} models.Project "The projects" // @Failure 403 {object} web.HTTPError "The user does not have access to the project" @@ -219,6 +227,17 @@ func (p *Project) ReadAll(s *xorm.Session, a web.Auth, search string, page int, return } + if p.Expand == ProjectExpandableRights { + err = addMaxRightToProjects(s, prs, a) + if err != nil { + return + } + } else { + for _, pr := range prs { + pr.MaxRight = RightUnknown + } + } + ////////////////////////// // Putting it all together @@ -719,6 +738,31 @@ func addProjectDetails(s *xorm.Session, projects []*Project, a web.Auth) (err er return } +func addMaxRightToProjects(s *xorm.Session, projects []*Project, a web.Auth) (err error) { + projectIDs := make([]int64, 0, len(projects)) + for _, project := range projects { + if getSavedFilterIDFromProjectID(project.ID) > 0 { + project.MaxRight = RightAdmin + continue + } + projectIDs = append(projectIDs, project.ID) + } + + rights, err := checkRightsForProjects(s, a, projectIDs) + if err != nil { + return err + } + + for _, project := range projects { + right, has := rights[project.ID] + if has { + project.MaxRight = right.MaxRight + } + } + + return +} + // CheckIsArchived returns an ErrProjectIsArchived if the project or any of its parent projects is archived. func (p *Project) CheckIsArchived(s *xorm.Session) (err error) { if p.ParentProjectID > 0 { diff --git a/pkg/models/project_rights.go b/pkg/models/project_rights.go index 09785389e..2093d0c97 100644 --- a/pkg/models/project_rights.go +++ b/pkg/models/project_rights.go @@ -18,10 +18,11 @@ package models import ( "errors" + "strings" "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/web" - "xorm.io/builder" + "xorm.io/xorm" ) @@ -105,9 +106,6 @@ func (p *Project) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) { (shareAuth.Right == RightRead || shareAuth.Right == RightWrite || shareAuth.Right == RightAdmin), int(shareAuth.Right), nil } - if p.isOwner(&user.User{ID: a.GetID()}) { - return true, int(RightAdmin), nil - } return p.checkRight(s, a, RightRead, RightWrite, RightAdmin) } @@ -211,66 +209,85 @@ func (p *Project) isOwner(u *user.User) bool { // Checks n different rights for any given user func (p *Project) checkRight(s *xorm.Session, a web.Auth, rights ...Right) (bool, int, error) { + projectRights, err := checkRightsForProjects(s, a, []int64{p.ID}) + if err != nil { + return false, 0, err + } + right, has := projectRights[p.ID] + if !has { + return false, 0, nil + } - var conds []builder.Cond for _, r := range rights { - // User conditions - // If the project was shared directly with the user and the user has the right - conds = append(conds, builder.And( - builder.Eq{"ul.user_id": a.GetID()}, - builder.Eq{"ul.right": r}, - )) - - // Team rights - // If the project was shared directly with the team and the team has the right - conds = append(conds, builder.And( - builder.Eq{"tm2.user_id": a.GetID()}, - builder.Eq{"tl.right": r}, - )) - } - - type allProjectRights struct { - UserProject *ProjectUser `xorm:"extends"` - TeamProject *TeamProject `xorm:"extends"` - } - - r := &allProjectRights{} - var maxRight = 0 - exists, err := s. - Select("p.*, ul.right, tl.right"). - Table("projects"). - Alias("p"). - // User stuff - Join("LEFT", []string{"users_projects", "ul"}, "ul.project_id = p.id"). - // Team stuff - Join("LEFT", []string{"team_projects", "tl"}, "p.id = tl.project_id"). - Join("LEFT", []string{"team_members", "tm2"}, "tm2.team_id = tl.team_id"). - // The actual condition - Where(builder.And( - builder.Or( - conds..., - ), - builder.Eq{"p.id": p.ID}, - )). - Get(r) - - // If there's noting shared for this project, and it has a parent, go up the tree - if !exists && p.ParentProjectID > 0 { - parent, err := GetProjectSimpleByID(s, p.ParentProjectID) - if err != nil { - return false, 0, err + if r == right.MaxRight { + return true, int(right.MaxRight), nil } - - return parent.checkRight(s, a, rights...) } - // Figure out the max right and return it - if int(r.UserProject.Right) > maxRight { - maxRight = int(r.UserProject.Right) - } - if int(r.TeamProject.Right) > maxRight { - maxRight = int(r.TeamProject.Right) - } - - return exists, maxRight, err + return false, 0, nil +} + +type projectRight struct { + ID int64 `xorm:"pk autoincr"` + MaxRight Right +} + +func checkRightsForProjects(s *xorm.Session, a web.Auth, projectIDs []int64) (projectRightMap map[int64]*projectRight, err error) { + projectRightMap = make(map[int64]*projectRight) + whereIDIn := strings.Repeat("?,", len(projectIDs))[:len(projectIDs)*2-1] + args := []interface{}{ + a.GetID(), + a.GetID(), + a.GetID(), + int64(0), + } + for _, id := range projectIDs { + args = append(args, id) + } + + err = s.SQL(`WITH RECURSIVE + all_projects AS (SELECT p.id, + p.parent_project_id, + CASE + WHEN p.owner_id = 1 THEN 2 + WHEN COALESCE(ul.right, 0) > COALESCE(tl.right, 0) THEN ul.right + ELSE COALESCE(tl.right, 0) + END AS initial_right + FROM projects p + LEFT JOIN team_projects tl ON tl.project_id = p.id + LEFT JOIN team_members tm2 ON tm2.team_id = tl.team_id + LEFT JOIN users_projects ul ON ul.project_id = p.id + WHERE (tm2.user_id = ? OR ul.user_id = ? OR p.owner_id = ?) + AND (p.parent_project_id IS NULL OR p.parent_project_id = ? OR + ((tm2.user_id IS NOT NULL OR ul.user_id IS NOT NULL) AND + p.parent_project_id IS NOT NULL)) + AND p.id in (`+whereIDIn+`) + GROUP BY p.id + + UNION ALL + + SELECT p.id, p.parent_project_id, ap.initial_right + FROM projects p + INNER JOIN all_projects ap ON p.parent_project_id = ap.id), + + project_max_rights AS (SELECT id, MAX(initial_right) AS max_right + FROM all_projects + GROUP BY id), + + inherited_rights AS (SELECT ap.id, + CASE + WHEN COALESCE(pmr.max_right, 0) > COALESCE(parent.max_right, 0) + THEN COALESCE(pmr.max_right, 0) + ELSE COALESCE(parent.max_right, 0) + END AS inherited_right + FROM all_projects ap + LEFT JOIN project_max_rights pmr ON ap.id = pmr.id + LEFT JOIN project_max_rights parent ON ap.parent_project_id = parent.id) + +SELECT DISTINCT ap.id, + ir.inherited_right AS max_right +FROM all_projects ap + LEFT JOIN all_projects np ON ap.parent_project_id = np.id + LEFT JOIN inherited_rights ir ON ap.id = ir.id`, args...).Find(&projectRightMap) + return } diff --git a/pkg/models/rights.go b/pkg/models/rights.go index b25aedeca..64df592f0 100644 --- a/pkg/models/rights.go +++ b/pkg/models/rights.go @@ -16,18 +16,20 @@ package models +import ( + "encoding/json" + "fmt" + "strconv" +) + // Right defines the rights users/teams can have for projects type Right int // define unknown right const ( RightUnknown = -1 -) - -// Enumerate all the team rights -const ( // Can read projects in a - RightRead Right = iota + RightRead Right = iota - 1 // Can write in a like projects and tasks. Cannot create new projects. RightWrite // Can manage a project, can do everything @@ -41,3 +43,33 @@ func (r Right) isValid() error { return nil } + +// MarshalJSON marshals the enum as a quoted json string +func (r Right) MarshalJSON() ([]byte, error) { + if r == RightUnknown { + return []byte(`null`), nil + } + return []byte(strconv.Itoa(int(r))), nil +} + +// UnmarshalJSON unmarshals a quoted json string to the enum value +func (r *Right) UnmarshalJSON(data []byte) error { + var s int + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + switch s { + case -1: + *r = RightUnknown + case 0: + *r = RightRead + case 1: + *r = RightWrite + case 2: + *r = RightAdmin + default: + return fmt.Errorf("invalid Right %q", s) + } + return nil +}