Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions invokeai/app/api/routers/boards.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy
from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy, BoardVisibility
from invokeai.app.services.boards.boards_common import BoardDTO
from invokeai.app.services.image_records.image_records_common import ImageCategory
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
Expand Down Expand Up @@ -56,7 +56,14 @@ async def get_board(
except Exception:
raise HTTPException(status_code=404, detail="Board not found")

if not current_user.is_admin and result.user_id != current_user.user_id:
# Admins can access any board.
# Owners can access their own boards.
# Shared and public boards are visible to all authenticated users.
if (
not current_user.is_admin
and result.user_id != current_user.user_id
and result.board_visibility == BoardVisibility.Private
):
raise HTTPException(status_code=403, detail="Not authorized to access this board")

return result
Expand Down Expand Up @@ -188,7 +195,11 @@ async def list_all_board_image_names(
except Exception:
raise HTTPException(status_code=404, detail="Board not found")

if not current_user.is_admin and board.user_id != current_user.user_id:
if (
not current_user.is_admin
and board.user_id != current_user.user_id
and board.board_visibility == BoardVisibility.Private
):
raise HTTPException(status_code=403, detail="Not authorized to access this board")

image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
Expand Down
22 changes: 22 additions & 0 deletions invokeai/app/services/board_records/board_records_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@
from invokeai.app.util.model_exclude_null import BaseModelExcludeNull


class BoardVisibility(str, Enum, metaclass=MetaEnum):
"""The visibility options for a board."""

Private = "private"
"""Only the board owner (and admins) can see and modify this board."""
Shared = "shared"
"""All users can view this board, but only the owner (and admins) can modify it."""
Public = "public"
"""All users can view this board; only the owner (and admins) can modify its structure."""


class BoardRecord(BaseModelExcludeNull):
"""Deserialized board record."""

Expand All @@ -28,6 +39,10 @@ class BoardRecord(BaseModelExcludeNull):
"""The name of the cover image of the board."""
archived: bool = Field(description="Whether or not the board is archived.")
"""Whether or not the board is archived."""
board_visibility: BoardVisibility = Field(
default=BoardVisibility.Private, description="The visibility of the board."
)
"""The visibility of the board (private, shared, or public)."""


def deserialize_board_record(board_dict: dict) -> BoardRecord:
Expand All @@ -44,6 +59,11 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord:
updated_at = board_dict.get("updated_at", get_iso_timestamp())
deleted_at = board_dict.get("deleted_at", get_iso_timestamp())
archived = board_dict.get("archived", False)
board_visibility_raw = board_dict.get("board_visibility", BoardVisibility.Private.value)
try:
board_visibility = BoardVisibility(board_visibility_raw)
except ValueError:
board_visibility = BoardVisibility.Private

return BoardRecord(
board_id=board_id,
Expand All @@ -54,13 +74,15 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord:
updated_at=updated_at,
deleted_at=deleted_at,
archived=archived,
board_visibility=board_visibility,
)


class BoardChanges(BaseModel, extra="forbid"):
board_name: Optional[str] = Field(default=None, description="The board's new name.", max_length=300)
cover_image_name: Optional[str] = Field(default=None, description="The name of the board's new cover image.")
archived: Optional[bool] = Field(default=None, description="Whether or not the board is archived")
board_visibility: Optional[BoardVisibility] = Field(default=None, description="The visibility of the board.")


class BoardRecordOrderBy(str, Enum, metaclass=MetaEnum):
Expand Down
22 changes: 17 additions & 5 deletions invokeai/app/services/board_records/board_records_sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
BoardRecordNotFoundException,
BoardRecordOrderBy,
BoardRecordSaveException,
BoardVisibility,

Check failure on line 12 in invokeai/app/services/board_records/board_records_sqlite.py

View workflow job for this annotation

GitHub Actions / python-checks

Ruff (F401)

invokeai/app/services/board_records/board_records_sqlite.py:12:5: F401 `invokeai.app.services.board_records.board_records_common.BoardVisibility` imported but unused
deserialize_board_record,
)
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
Expand Down Expand Up @@ -116,6 +117,17 @@
(changes.archived, board_id),
)

# Change the visibility of a board
if changes.board_visibility is not None:
cursor.execute(
"""--sql
UPDATE boards
SET board_visibility = ?
WHERE board_id = ?;
""",
(changes.board_visibility.value, board_id),
)

except sqlite3.Error as e:
raise BoardRecordSaveException from e
return self.get(board_id)
Expand Down Expand Up @@ -155,7 +167,7 @@
SELECT DISTINCT boards.*
FROM boards
LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id
WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.is_public = 1)
WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public'))
{archived_filter}
ORDER BY {order_by} {direction}
LIMIT ? OFFSET ?;
Expand Down Expand Up @@ -194,14 +206,14 @@
SELECT COUNT(DISTINCT boards.board_id)
FROM boards
LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id
WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.is_public = 1);
WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public'));
"""
else:
count_query = """
SELECT COUNT(DISTINCT boards.board_id)
FROM boards
LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id
WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.is_public = 1)
WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public'))
AND boards.archived = 0;
"""

Expand Down Expand Up @@ -251,7 +263,7 @@
SELECT DISTINCT boards.*
FROM boards
LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id
WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.is_public = 1)
WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public'))
{archived_filter}
ORDER BY LOWER(boards.board_name) {direction}
"""
Expand All @@ -260,7 +272,7 @@
SELECT DISTINCT boards.*
FROM boards
LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id
WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.is_public = 1)
WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public'))
{archived_filter}
ORDER BY {order_by} {direction}
"""
Expand Down
2 changes: 2 additions & 0 deletions invokeai/app/services/shared/sqlite/sqlite_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_26 import build_migration_26
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_27 import build_migration_27
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_28 import build_migration_28
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_29 import build_migration_29
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator


Expand Down Expand Up @@ -79,6 +80,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto
migrator.register_migration(build_migration_26(app_config=config, logger=logger))
migrator.register_migration(build_migration_27())
migrator.register_migration(build_migration_28())
migrator.register_migration(build_migration_29())
migrator.run_migrations()

return db
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Migration 29: Add board_visibility column to boards table.

This migration adds a board_visibility column to the boards table to support
three visibility levels:
- 'private': only the board owner (and admins) can view/modify
- 'shared': all users can view, but only the owner (and admins) can modify
- 'public': all users can view; only the owner (and admins) can modify the
board structure (rename/archive/delete)

Existing boards with is_public = 1 are migrated to 'public'.
All other existing boards default to 'private'.
"""

import sqlite3

from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration


class Migration29Callback:
"""Migration to add board_visibility column to the boards table."""

def __call__(self, cursor: sqlite3.Cursor) -> None:
self._update_boards_table(cursor)

def _update_boards_table(self, cursor: sqlite3.Cursor) -> None:
"""Add board_visibility column to boards table."""
# Check if boards table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='boards';")
if cursor.fetchone() is None:
return

cursor.execute("PRAGMA table_info(boards);")
columns = [row[1] for row in cursor.fetchall()]

if "board_visibility" not in columns:
cursor.execute("ALTER TABLE boards ADD COLUMN board_visibility TEXT NOT NULL DEFAULT 'private';")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_board_visibility ON boards(board_visibility);")
# Migrate existing is_public = 1 boards to 'public'
if "is_public" in columns:
cursor.execute("UPDATE boards SET board_visibility = 'public' WHERE is_public = 1;")


def build_migration_29() -> Migration:
"""Builds the migration object for migrating from version 28 to version 29.

This migration adds the board_visibility column to the boards table,
supporting 'private', 'shared', and 'public' visibility levels.
"""
return Migration(
from_version=28,
to_version=29,
callback=Migration29Callback(),
)
12 changes: 11 additions & 1 deletion invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,17 @@
"imagesWithCount_other": "{{count}} images",
"assetsWithCount_one": "{{count}} asset",
"assetsWithCount_other": "{{count}} assets",
"updateBoardError": "Error updating board"
"updateBoardError": "Error updating board",
"setBoardVisibility": "Set Board Visibility",
"setVisibilityPrivate": "Set Private",
"setVisibilityShared": "Set Shared",
"setVisibilityPublic": "Set Public",
"visibilityPrivate": "Private",
"visibilityShared": "Shared",
"visibilityPublic": "Public",
"visibilityBadgeShared": "Shared board",
"visibilityBadgePublic": "Public board",
"updateBoardVisibilityError": "Error updating board visibility"
},
"accordions": {
"generation": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Combobox, ConfirmationAlertDialog, Flex, FormControl, Text } from '@inv
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { selectCurrentUser } from 'features/auth/store/authSlice';
import {
changeBoardReset,
isModalOpenChanged,
Expand All @@ -13,6 +14,7 @@ import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { useAddImagesToBoardMutation, useRemoveImagesFromBoardMutation } from 'services/api/endpoints/images';
import type { BoardDTO } from 'services/api/types';

const selectImagesToChange = createSelector(
selectChangeBoardModalSlice,
Expand All @@ -28,6 +30,7 @@ const ChangeBoardModal = () => {
useAssertSingleton('ChangeBoardModal');
const dispatch = useAppDispatch();
const currentBoardId = useAppSelector(selectSelectedBoardId);
const currentUser = useAppSelector(selectCurrentUser);
const [selectedBoardId, setSelectedBoardId] = useState<string | null>();
const { data: boards, isFetching } = useListAllBoardsQuery({ include_archived: true });
const isModalOpen = useAppSelector(selectIsModalOpen);
Expand All @@ -36,18 +39,28 @@ const ChangeBoardModal = () => {
const [removeImagesFromBoard] = useRemoveImagesFromBoardMutation();
const { t } = useTranslation();

// Returns true if the current user can write images to the given board.
const canWriteToBoard = useCallback(
(board: BoardDTO): boolean => {
const isOwnerOrAdmin = !currentUser || currentUser.is_admin || board.user_id === currentUser.user_id;
return isOwnerOrAdmin || board.board_visibility === 'public';
},
[currentUser]
);

const options = useMemo<ComboboxOption[]>(() => {
return [{ label: t('boards.uncategorized'), value: 'none' }]
.concat(
(boards ?? [])
.filter(canWriteToBoard)
.map((board) => ({
label: board.board_name,
value: board.board_id,
}))
.sort((a, b) => a.label.localeCompare(b.label))
)
.filter((board) => board.value !== currentBoardId);
}, [boards, currentBoardId, t]);
}, [boards, canWriteToBoard, currentBoardId, t]);

const value = useMemo(() => options.find((o) => o.value === selectedBoardId), [options, selectedBoardId]);

Expand Down
Loading
Loading