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
This commit is contained in:
kolaente 2024-10-13 19:22:09 +02:00
parent 3e9c41cfc6
commit 92b90013ab
No known key found for this signature in database
GPG Key ID: F40E70337AB24C9B
7 changed files with 169 additions and 71 deletions

View File

@ -25,7 +25,7 @@
/>
<div
class="color-bubble-handle-wrapper"
:class="{'is-draggable': project.id > 0}"
:class="{'is-draggable': project.id > 0 && project.maxRight > RIGHTS.READ}"
>
<ColorBubble
v-if="project.hexColor !== ''"
@ -38,7 +38,7 @@
<Icon icon="filter" />
</span>
<span
v-if="project.id > 0"
v-if="project.id > 0 && project.maxRight > RIGHTS.READ"
class="icon menu-item-icon handle"
:class="{'has-color-bubble': project.hexColor !== ''}"
>
@ -48,7 +48,7 @@
<span class="project-menu-title">{{ getProjectTitle(project) }}</span>
</BaseButton>
<BaseButton
v-if="project.id > 0"
v-if="project.id > 0 && project.maxRight > RIGHTS.READ"
class="favorite"
:class="{'is-favorite': project.isFavorite}"
@click="projectStore.toggleProjectFavorite(project)"
@ -56,6 +56,7 @@
<Icon :icon="project.isFavorite ? 'star' : ['far', 'star']" />
</BaseButton>
<ProjectSettingsDropdown
v-if="project.maxRight > RIGHTS.READ"
class="menu-list-dropdown"
:project="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,

View File

@ -109,6 +109,7 @@
{{ $t('menu.createProject') }}
</DropdownItem>
<DropdownItem
v-if="project.maxRight === RIGHTS.ADMIN"
v-tooltip="isDefaultProject ? $t('menu.cantDeleteIsDefault') : ''"
:to="{ name: 'project.settings.delete', params: { projectId: project.id } }"
icon="trash-alt"
@ -135,6 +136,7 @@ import {isSavedFilter} from '@/services/savedFilter'
import {useConfigStore} from '@/stores/config'
import {useProjectStore} from '@/stores/projects'
import {useAuthStore} from '@/stores/auth'
import {RIGHTS} from '@/constants/rights'
const props = defineProps<{
project: IProject

View File

@ -185,7 +185,7 @@ export const useProjectStore = defineStore('project', () => {
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)

View File

@ -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'],

View File

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

View File

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

View File

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