Skip to content

views

AYON Views subpackage.

Saved configurations (columns, sort, filter, grouping…) that can be applied to a :class:PaginatedTableModel + :class:AYTableView / :class:AYCardView setup.

Phase 1 ships the data model and storage backend. Phase 2 adds the :class:ViewBindings adapter and the :class:AYViewSelector / :class:AYViewEditor widgets.

AYViewEditor

Bases: QDialog

Modal editor for the metadata fields of a :class:View.

Parameters:

Name Type Description Default
view View

The :class:View to edit. Pass a fresh View() with an empty id to create a new view.

required
current_user str

User identifier of the editor. Used to gate the studio scope when applicable.

''
allow_studio_scope bool

Whether Scope.STUDIO is offered to the user. Consumer apps gate this via their permission model.

False
usernames_and_groups dict[str, list] | None

Users and access groups offered in the "People with access" selector.

None
allow_sharing bool

Whether the "People with access" section is shown. When False the view's access is left unchanged.

True
parent QWidget | None

Optional parent widget.

None
Source code in client/ayon_core/ui/components/views/view_editor.py
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
class AYViewEditor(QDialog):
    """Modal editor for the metadata fields of a :class:`View`.

    Args:
        view: The :class:`View` to edit.  Pass a fresh ``View()`` with
            an empty ``id`` to create a new view.
        current_user: User identifier of the editor.  Used to gate the
            studio scope when applicable.
        allow_studio_scope: Whether ``Scope.STUDIO`` is offered to the
            user.  Consumer apps gate this via their permission model.
        usernames_and_groups: Users and access groups offered in the
            "People with access" selector.
        allow_sharing: Whether the "People with access" section is
            shown.  When False the view's access is left unchanged.
        parent: Optional parent widget.
    """

    class Mode(IntEnum):
        """Dialog mode: create or edit."""

        CREATE = 0
        EDIT = 1

    def __init__(
        self,
        view: View,
        current_user: str = "",
        current_project: str = "",
        allow_studio_scope: bool = False,
        usernames_and_groups: dict[str, list] | None = None,
        allow_sharing: bool = True,
        parent: QWidget | None = None,
    ) -> None:
        self._dialog_mode = (
            AYViewEditor.Mode.EDIT if view.id else AYViewEditor.Mode.CREATE
        )

        super().__init__(parent)
        self.setStyle(get_ayon_style())
        self.setWindowTitle(
            "Edit View"
            if self._dialog_mode == AYViewEditor.Mode.EDIT
            else "Create New View"
        )
        self.setModal(True)
        self.setContentsMargins(0, 0, 0, 0)

        self._view = view
        self._current_user = current_user
        self.usernames_and_groups = usernames_and_groups or {
            "users": [],
            "groups": [],
        }
        self._current_project = current_project
        self._allow_studio_scope = bool(allow_studio_scope)
        self._allow_sharing = bool(allow_sharing)
        self._delete_requested = False

        # Access control state: {key: access_level}
        #   where key is "user:name" or "group:name"
        self._access_dict: dict[str, int] = {}
        # Stores row widgets by access key
        self._access_row_widgets: dict[str, QWidget] = {}

        self._build_ui()
        self._load_from_view(view)

    # ------------------------------------------------------------------
    # UI construction
    # ------------------------------------------------------------------

    def _build_ui(self) -> None:
        """Create the form and button row."""
        root = AYVBoxLayout(self, spacing=0, margin=0)

        static_layout = AYContainer(
            layout=AYContainer.Layout.VBox,
            layout_margin=20,
            layout_spacing=12,
        )

        static_layout.add_widget(AYLabel("View name"))

        # view name field — required.
        self._view_name_edit = AYLineEdit(placeholder="View name")
        static_layout.add_widget(self._view_name_edit)

        # Scope combo (project / all projects).
        self._scope_combo = AYComboBox()
        scope_items = [
            {
                "text": f"Project - {self._current_project}",
                "short_text": "Project",
            }
        ]
        if self._allow_studio_scope:
            scope_items.append(
                {"text": "All Projects", "short_text": "All Projects"}
            )
        self._scope_combo.update_items(scope_items)
        static_layout.add_widget(AYLabel("Scope"))
        static_layout.add_widget(self._scope_combo)

        # Everything about sharing lives in one section, so it can be
        # hidden as a whole when sharing is unavailable.
        access_section = AYContainer(
            layout=AYContainer.Layout.VBox,
            layout_margin=0,
            layout_spacing=12,
        )
        access_section.setVisible(self._allow_sharing)
        static_layout.add_widget(access_section)

        # User/group selector dropdown
        self._user_selector = AYSearchableComboBox(
            placeholder="Add people or access groups"
        )

        access_section.add_widget(AYLabel("People with access"))
        access_section.add_widget(self._user_selector)

        self._user_selector.item_selected.connect(self._on_user_selected)

        # Owner row
        self._owner_avatar = AYUserImage(
            name="",
            full_name="",
            size=24,
            outline=False,
        )
        self._owner_avatar.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
        self._owner_label = AYLabel("-")

        owner_row = AYContainer(
            layout=AYContainer.Layout.HBox,
            layout_spacing=8,
            layout_margin=0,
        )
        owner_row.add_widget(self._owner_avatar, stretch=0)
        owner_row.add_widget(self._owner_label, stretch=0)
        owner_row.addStretch()
        owner_row.add_widget(AYLabel("Owner", dim=True))
        owner_row.layout().addSpacerItem(
            QSpacerItem(32, 0, QSizePolicy.Fixed, QSizePolicy.Minimum)
        )
        access_section.add_widget(owner_row)

        # Everyone row
        self._everyone_combo = AYComboBox()
        access_items = [
            {"text": ACCESS_LEVELS[level]} for level in ACCESS_LEVEL_VALUES
        ]
        self._everyone_combo.update_items(access_items)
        self._everyone_combo.setFixedWidth(120)

        def on_everyone_changed(idx: int) -> None:
            self._access_dict["__everyone__"] = ACCESS_LEVEL_VALUES[idx]

        self._everyone_combo.currentIndexChanged.connect(on_everyone_changed)

        everyone_row = AYContainer(
            layout=AYContainer.Layout.HBox,
            layout_spacing=8,
            layout_margin=0,
        )
        everyone_icon = AYLabel(icon="groups", icon_size=24)
        everyone_row.add_widget(everyone_icon, stretch=0)
        everyone_row.add_widget(AYLabel("Everyone"), stretch=0)
        everyone_row.addStretch()
        everyone_row.add_widget(self._everyone_combo)
        everyone_row.layout().addSpacerItem(
            QSpacerItem(32, 0, QSizePolicy.Fixed, QSizePolicy.Minimum)
        )
        access_section.add_widget(everyone_row)

        # Dynamic user/group access rows
        self._access_rows_container = AYContainer(
            layout=AYContainer.Layout.VBox,
            layout_spacing=4,
            layout_margin=0,
        )
        access_section.add_widget(self._access_rows_container)

        root.addWidget(static_layout)
        root.addStretch()

        # Button row - positioned at bottom with Delete on left, Save on right
        button_container = QWidget()
        button_layout = QHBoxLayout(button_container)
        button_layout.setContentsMargins(16, 16, 16, 16)
        button_layout.setSpacing(8)

        save_btn = AYButton(
            "Save"
            if self._dialog_mode == AYViewEditor.Mode.EDIT
            else "Create",
            variant=AYButton.Variants.Filled,
            icon="check",
        )
        delete_btn = AYButton(
            "Delete", variant=AYButton.Variants.Danger, icon="delete"
        )

        if self._dialog_mode == AYViewEditor.Mode.EDIT:
            # Delete on left, stretch, Save on right
            button_layout.addWidget(delete_btn)
            button_layout.addStretch()
            button_layout.addWidget(save_btn)
            delete_btn.clicked.connect(self._on_delete)
        else:
            # Just Save on right for create mode
            button_layout.addStretch()
            button_layout.addWidget(save_btn)

        save_btn.clicked.connect(self._on_accept)

        root.addWidget(button_container)

        self.setMinimumWidth(500)
        self.setContentsMargins(0, 0, 0, 0)

    # ------------------------------------------------------------------
    # Data loading
    # ------------------------------------------------------------------

    def _load_from_view(self, view: View) -> None:
        """Populate widgets from *view*.

        Args:
            view: The view to copy values from.
        """
        self._view_name_edit.setText(view.label)

        # Scope: select matching item, falling back to project when
        # studio is disallowed but the view is scoped studio.
        if view.scope == Scope.STUDIO and self._allow_studio_scope:
            self._scope_combo.setCurrentIndex(1)
        else:
            self._scope_combo.setCurrentIndex(0)

        # Load access data from view
        self._access_dict = dict(view.access) if view.access else {}

        # Update Owner
        owner_name = self._view.owner or self._current_user or "-"
        self._owner_label.setText(owner_name)

        # Recreate owner avatar with updated name
        self._owner_avatar._name = owner_name
        self._owner_avatar._full_name = owner_name
        self._owner_avatar.set_image()

        # Update Everyone
        everyone_level = self._access_dict.get("__everyone__", 0)
        if everyone_level not in ACCESS_LEVEL_VALUES:
            everyone_level = 0
        self._access_dict["__everyone__"] = everyone_level
        self._everyone_combo.setCurrentIndex(
            ACCESS_LEVEL_VALUES.index(everyone_level)
        )

        self._populate_access_rows()
        self._update_user_dropdown()

    def _update_user_dropdown(self) -> None:
        """Update dropdown to show only users/groups not yet added."""
        added_keys = set(self._access_dict.keys())

        items = []
        users = self.usernames_and_groups.get("users", [])
        groups = self.usernames_and_groups.get("groups", [])

        if users:
            for user in users:
                user_name = user.get("name", "")
                user_full_name = user.get("fullName", "") or user_name
                user_key = f"user:{user_name}"
                if user_key not in added_keys:
                    items.append({
                        "key": user_key,
                        "label": user_full_name,
                        "identifier": user_name,
                        "type": "user"
                    })

        for group in groups:
            group_key = f"group:{group}"
            if group_key not in added_keys:
                items.append({
                    "key": group_key,
                    "label": group.capitalize(),
                    "identifier": "group",
                    "type": "group"
                })

        # Set items
        self._user_selector.set_items_with_builder(
            items,
            row_builder=self._build_access_list_row
        )

    def _on_user_selected(self, access_key: str) -> None:
        """Handle user selection from dropdown."""
        if not access_key:
            return

        self._add_access_row(access_key, 10)  # Default to Viewer access
        self._update_user_dropdown()

    def _add_access_row(self, key: str, access_level: int) -> None:
        """Add an access control row for the given user/group.

        Args:
            key: The access key (e.g., "user:name" or "group:name").
            access_level: The access level (0, 10, 20, or 30).
        """
        if key in self._access_dict:
            return  # Already exists

        self._access_dict[key] = access_level
        self._populate_access_rows()

    def _remove_access_row(self, key: str) -> None:
        """Remove an access control row.

        Args:
            key: The access key to remove.
        """
        if key not in self._access_dict:
            return

        del self._access_dict[key]

        self._populate_access_rows()
        self._update_user_dropdown()

    def _render_access_row(self, key: str, access_level: int) -> None:
        """Render a single access row"""
        row = AYContainer(
            layout=AYContainer.Layout.HBox,
            layout_spacing=4,
            layout_margin=0,
        )

        access_combo = AYComboBox(show_chevron=True)
        access_combo.setFixedWidth(120)
        access_items = [
            {"text": ACCESS_LEVELS[level]}
            for level in ACCESS_LEVEL_VALUES
        ]
        access_combo.update_items(access_items)

        if access_level not in ACCESS_LEVEL_VALUES:
            access_level = 10
        access_combo.setCurrentIndex(ACCESS_LEVEL_VALUES.index(access_level))

        def on_access_changed(idx: int) -> None:
            self._access_dict[key] = ACCESS_LEVEL_VALUES[idx]

        access_combo.currentIndexChanged.connect(on_access_changed)

        remove_btn = AYButton("", icon="close", variant=AYButton.Variants.Nav)
        remove_btn.setFixedWidth(32)
        remove_btn.clicked.connect(lambda: self._remove_access_row(key))

        label_widget = self._build_access_row_label(key)
        row.add_widget(label_widget, stretch=1)
        row.addStretch()
        row.add_widget(access_combo)
        row.add_widget(remove_btn)

        self._access_rows_container.add_widget(row)
        self._access_row_widgets[key] = row

    def _populate_access_rows(self) -> None:
        """Populate access control rows from _access_dict."""
        # Clear existing rows
        for widget in self._access_row_widgets.values():
            widget.setVisible(False)
            widget.deleteLater()
        self._access_row_widgets.clear()

        self._access_rows_container.clear()

        # Add user/group rows
        for key in sorted(self._access_dict.keys()):
            if key == "__everyone__":
                continue
            self._render_access_row(key, int(self._access_dict[key]))

    def _build_access_list_row(self, row, entry: dict) -> None:
        """Build custom identifier widgets for a dropdown row.

        This row builder is used with :meth:`AYSearchableComboBox.set_items_with_builder`
        to add avatar and identifier labels to user/group rows.

        Args:
            row: The _ItemRow widget to customize.
            entry: The item dict containing metadata.
        """  # noqa: E501
        entry_type = entry.get("type", "")
        identifier = entry.get("identifier", "")
        label = entry.get("label", "")

        # Insert avatar/icon at the beginning for users and groups
        if entry_type == "user":
            avatar = AYUserImage(
                name=identifier,
                full_name=label,
                size=20,
                outline=False,
            )
            avatar.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
            row.layout().insertWidget(0, avatar)
        elif entry_type == "group":
            group_icon = AYLabel(
                icon="shield_person",
                icon_size=20,
            )
            row.layout().insertWidget(0, group_icon)

        if "identifier" in entry:
            # Add spacer item with stretch
            row.layout().addSpacerItem(
                QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum)
            )
            # Add identifier label (dim)
            id_label = AYLabel(f"({identifier})", dim=True)
            row.add_custom_widget(id_label, stretch=0)

    def _build_access_row_label(self, key: str) -> AYContainer:
        """Build formatted label widget for access control row.

        For users: Shows avatar with "Full Name" and "(username)" in dim text
        For groups: Shows capitalized group name

        Args:
            key: The access key (e.g., "user:name" or "group:name")

        Returns:
            AYContainer with formatted widgets for the access row.
        """
        if key.startswith("user:"):
            # Extract username from key
            user_name = key.replace("user:", "")

            # Find the full name from usernames_and_groups
            user_full_name = user_name
            for user in self.usernames_and_groups["users"]:
                if user["name"] == user_name:
                    user_full_name = user["fullName"] or user_name
                    break

            # Create container with avatar and text
            user_row_container = AYContainer(
                layout=AYContainer.Layout.HBox,
                layout_spacing=8,
                layout_margin=0,
            )

            # Add Avatar
            avatar = AYUserImage(
                name=user_name,
                full_name=user_full_name,
                size=24,
                outline=False,
                parent=user_row_container,
            )
            avatar.setSizePolicy(
                QSizePolicy.Fixed,
                QSizePolicy.Fixed,
            )
            user_row_container.add_widget(avatar, stretch=0)

            # Add full name
            user_row_container.add_widget(
                AYLabel(user_full_name),
                stretch=0
            )
            user_row_container.add_widget(
                AYLabel(f"({user_name})", dim=True),
                stretch=0
            )
            user_row_container.layout().addSpacerItem(
                QSpacerItem(
                    0,
                    0,
                    QSizePolicy.Expanding,
                    QSizePolicy.Minimum,
                )
            )

            return user_row_container

        # Group access row
        group_name = key.replace("group:", "")

        # Create container with groups icon and name
        group_row_container = AYContainer(
            layout=AYContainer.Layout.HBox,
            layout_spacing=8,
            layout_margin=0,
        )

        # Add groups icon
        group_icon = AYLabel(
            icon="shield_person",
            icon_size=24,
        )
        group_row_container.add_widget(group_icon, stretch=0)

        # Add group name
        group_row_container.add_widget(
            AYLabel(group_name.capitalize()),
            stretch=0
        )
        group_row_container.layout().addSpacerItem(
            QSpacerItem(
                0,
                0,
                QSizePolicy.Expanding,
                QSizePolicy.Minimum,
            )
        )

        return group_row_container

    # ------------------------------------------------------------------
    # Accept handling
    # ------------------------------------------------------------------

    def _on_accept(self) -> None:
        """Validate inputs and accept the dialog.

        Labels must be non-empty; the dialog stays open otherwise.
        """
        label = self._view_name_edit.text().strip()
        if not label:
            self._view_name_edit.setFocus(Qt.FocusReason.OtherFocusReason)
            return

        self.accept()

    def _on_delete(self) -> None:
        """Delete the view and close the dialog."""
        self._delete_requested = True
        self.accept()

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def mode(self) -> AYViewEditor.Mode:
        """Return the dialog mode: create or edit."""
        return self._dialog_mode

    def delete_requested(self) -> bool:
        """Whether the user requested deletion of the view."""
        return self._delete_requested

    def get_view(self) -> View:
        """Return the edited :class:`View`.

        The original :attr:`View.settings`, :attr:`View.id`,
        :attr:`View.view_type`, :attr:`View.owner`, :attr:`View.access`,
        :attr:`View.position` and :attr:`View.extra` are kept intact;
        only the metadata edited in this dialog is mutated.

        Returns:
            The (mutated) original :class:`View` instance.
        """
        view = self._view
        view.label = self._view_name_edit.text().strip()

        if self._scope_combo.currentIndex() == 1 and self._allow_studio_scope:
            view.scope = Scope.STUDIO
        else:
            view.scope = Scope.PROJECT

        # The access editor is hidden without sharing; keep whatever
        # access the view already has rather than the editor defaults.
        if self._allow_sharing:
            view.access = dict(self._access_dict)

        if not view.owner and self._current_user:
            view.owner = self._current_user

        return view

Mode

Bases: IntEnum

Dialog mode: create or edit.

Source code in client/ayon_core/ui/components/views/view_editor.py
69
70
71
72
73
class Mode(IntEnum):
    """Dialog mode: create or edit."""

    CREATE = 0
    EDIT = 1

delete_requested()

Whether the user requested deletion of the view.

Source code in client/ayon_core/ui/components/views/view_editor.py
608
609
610
def delete_requested(self) -> bool:
    """Whether the user requested deletion of the view."""
    return self._delete_requested

get_view()

Return the edited :class:View.

The original :attr:View.settings, :attr:View.id, :attr:View.view_type, :attr:View.owner, :attr:View.access, :attr:View.position and :attr:View.extra are kept intact; only the metadata edited in this dialog is mutated.

Returns:

Type Description
View

The (mutated) original :class:View instance.

Source code in client/ayon_core/ui/components/views/view_editor.py
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
def get_view(self) -> View:
    """Return the edited :class:`View`.

    The original :attr:`View.settings`, :attr:`View.id`,
    :attr:`View.view_type`, :attr:`View.owner`, :attr:`View.access`,
    :attr:`View.position` and :attr:`View.extra` are kept intact;
    only the metadata edited in this dialog is mutated.

    Returns:
        The (mutated) original :class:`View` instance.
    """
    view = self._view
    view.label = self._view_name_edit.text().strip()

    if self._scope_combo.currentIndex() == 1 and self._allow_studio_scope:
        view.scope = Scope.STUDIO
    else:
        view.scope = Scope.PROJECT

    # The access editor is hidden without sharing; keep whatever
    # access the view already has rather than the editor defaults.
    if self._allow_sharing:
        view.access = dict(self._access_dict)

    if not view.owner and self._current_user:
        view.owner = self._current_user

    return view

mode()

Return the dialog mode: create or edit.

Source code in client/ayon_core/ui/components/views/view_editor.py
604
605
606
def mode(self) -> AYViewEditor.Mode:
    """Return the dialog mode: create or edit."""
    return self._dialog_mode

AYViewSelector

Bases: AYButtonMenu

Icon button exposing the Views feature.

Public API:

  • :meth:set_view_type — switch the view-type identifier and reload.
  • :meth:refresh — re-pull the view list from the manager.
  • :meth:current_view — return the active :class:View (or None).
Signals

view_applied(View): Emitted after a view has been applied to the bindings. view_saved(View): Emitted after a view has been persisted. view_deleted(str): Emitted with the view id after deletion. view_modified(str, bool): Emitted when the live UI no longer matches the loaded view settings, or when it returns to the saved state. Payload is (current_view_name, modified). binding_error(str, str): Emitted with (stage, message) when :class:ViewBindings reports a non-fatal failure while applying or capturing a view.

Source code in client/ayon_core/ui/components/views/view_selector.py
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
class AYViewSelector(AYButtonMenu):
    """Icon button exposing the Views feature.

    Public API:

    - :meth:`set_view_type` — switch the view-type identifier and reload.
    - :meth:`refresh` — re-pull the view list from the manager.
    - :meth:`current_view` — return the active :class:`View` (or
      ``None``).

    Signals:
        view_applied(View): Emitted after a view has been applied to
            the bindings.
        view_saved(View): Emitted after a view has been persisted.
        view_deleted(str): Emitted with the view id after deletion.
        view_modified(str, bool): Emitted when the live UI no longer
            matches the loaded view settings, or when it returns to the
            saved state. Payload is ``(current_view_name, modified)``.
        binding_error(str, str): Emitted with ``(stage, message)`` when
            :class:`ViewBindings` reports a non-fatal failure while
            applying or capturing a view.
    """

    view_applied = Signal(object)
    view_saved = Signal(object)
    view_deleted = Signal(str)
    view_modified = Signal(str, bool)
    binding_error = Signal(str, str)
    default_view_message = Signal(str, bool)

    def __init__(
        self,
        bindings: ViewBindings,
        manager: ViewManager,
        view_type: str,
        current_user: str = "",
        user_access_level: int = _DEFAULT_USER_ACCESS,
        allow_studio_scope: bool = False,
        parent: QWidget | None = None,
    ) -> None:
        # Store state before super().__init__() because the base class
        # invokes ``populate_callback`` during construction.
        self._bindings = bindings
        self._manager = manager
        self._view_type = view_type
        self._current_user = current_user
        self._user_access = int(user_access_level)
        self._allow_studio_scope = bool(allow_studio_scope)
        # Dirty flag: set on any UI change, cleared on apply/save/reset.
        # just highlight as soon as the user makes any change.
        self._view_modified = False

        self._current_view: View | None = None
        self._views: list[View] = []
        # The view list is only needed to draw the dropdown, so it is
        # fetched the first time the menu opens rather than at startup.
        self._views_loaded: bool = False
        # When True, :meth:`refresh` skips its auto-apply-working-view
        # branch.  Toggled by :class:`_SuspendAutoApply` around save
        # operations so the manager-driven refresh during save_view
        # does not trigger a redundant page-0 refetch.
        self._suppress_auto_apply: bool = False
        self._ensuring_working_view: bool = False
        # True while :meth:`_apply_view` is pushing settings into the
        # widgets. Those widgets emit the same change signals a user edit
        # does, and echoing them back to the server would rewrite the
        # working view with what we just read from it.
        self._applying_view: bool = False

        # Per-section collapsed state for the dropdown headers ("My
        # views", "Shared views", "Default view"). Kept for the
        # lifetime of the selector, mirroring the frontend's
        # collapsible section headers.
        self._collapsed_sections: dict[str, bool] = {}

        self._dropdown_layout = None  # type: ignore[assignment]
        self._default_view_control = DefaultViewControl(self)

        super().__init__(
            populate_callback=self._populate_menu,
            icon="view_quilt",
            variant=AYButton.Variants.Surface,
            dropdown_variant=AYContainer.Variants.Popover,
            dropdown_margin=6,
            tooltip="Views",
            parent=parent,
        )
        self.setObjectName("AYViewSelector")
        self.setFixedSize(32, 32)

        # Applying a view, or a single user gesture, moves several widgets
        # at once. Coalesce the resulting saves into one round trip.
        # Built here rather than with the state above: parenting a QTimer
        # needs this object's base class to be initialised first.
        self._working_view_timer = QTimer(self)
        self._working_view_timer.setSingleShot(True)
        self._working_view_timer.setInterval(300)
        self._working_view_timer.timeout.connect(self._update_working_view)

        # Rebuild the menu contents each time it opens, since the view
        # list may change between openings.
        self.menu_opened.connect(self._rebuild_menu)

        # Refresh when the manager changes.
        self._manager.views_changed.connect(self._on_manager_changed)
        self._manager.project_changed.connect(self._on_project_changed)
        self._connect_modified_state_sources()

        # Forward binding errors via the public ``binding_error`` signal so
        # hosts can surface them.  Overrides any pre-existing
        # ``on_error`` hook (the caller can wrap it themselves to chain).
        if self._bindings.on_error is None:
            self._bindings.on_error = self._on_binding_error

        self.refresh()
        # After refresh(), since it may apply a working view and set
        # its own "View: ..." tooltip — this call must have the final
        # say when there's no project, overriding that.
        self._update_enabled_for_project()

    # ------------------------------------------------------------------
    # Menu population
    # ------------------------------------------------------------------

    def _populate_menu(self, container: QFrame) -> None:
        """Initial populate callback invoked by :class:`AYButtonMenu`.

        We only cache the container's layout here; actual contents are
        (re)built on every menu open via :meth:`_rebuild_menu`.

        Args:
            container: The dropdown ``QFrame`` provided by the base.
        """
        layout = container.layout()
        if layout is not None:
            layout.setContentsMargins(4, 4, 4, 4)
            layout.setSpacing(2)
        self._dropdown_layout = layout

    def _rebuild_menu(self) -> None:
        """Rebuild the dropdown contents from the current view list."""
        layout = self._dropdown_layout
        if layout is None:
            return

        # Wipe previous contents.
        while layout.count():
            item = layout.takeAt(0)
            if item and item.widget():
                item.widget().deleteLater()

        # First open of the menu is what pays for the view list.
        self._ensure_views_listed()

        # The working view + "My views" + "Shared views" portion lives
        # in its own scrollable area, capped at _VIEWS_LIST_MAX_HEIGHT,
        # so a long view list scrolls instead of growing the dropdown
        # indefinitely — "Default view" and "Create new view…" stay
        # outside it, always visible.
        layout.addWidget(self._make_views_list_scroll_area())

        layout.addWidget(
            self._make_section_header("Default view", "default_view")
        )
        if not self._collapsed_sections.get("default_view"):
            layout.addWidget(self._make_default_view_row())
        layout.addWidget(self._make_separator())

        layout.addWidget(self._make_create_view_row())

        self.refresh_dropdown_size()

    def _make_views_list_scroll_area(self) -> AYScrollArea:
        """Build the scrollable working-view + "My views" + "Shared
        views" portion of the dropdown, capped at
        ``_VIEWS_LIST_MAX_HEIGHT``.
        """
        # A real, opaquely-painted Popover frame — same variant as the
        # dropdown and its rows — rather than a bare QWidget stylesheet
        # "background: transparent". The dropdown is a translucent
        # popup: a bare widget's "transparent" paints nothing of its
        # own, and any pixel none of its rows happen to cover (the
        # inter-row spacing, in particular) has nothing to fall back
        # to, so it stayed see-through — a visible darker gap/border
        # around the whole list. Painting Popover here directly makes
        # every pixel in the list opaque and correct, regardless of
        # what any translucent ancestor does or doesn't cover.
        scroll_content = AYContainer(
            layout=AYContainer.Layout.VBox,
            variant=AYContainer.Variants.Popover_Square,
            layout_spacing=2,
            layout_margin=0,
        )

        # Current view row with inline reset button.
        scroll_content.add_widget(
            self._make_working_view_row(self._current_view)
        )
        scroll_content.add_widget(self._make_separator())

        # Exclude working view as it stored with private Visibility
        private_views = [
            v for v in self._views
            if v.visibility == Visibility.PRIVATE
               and not v.working
               and v.label != DEFAULT_VIEW_LABEL
        ]
        public_views = [
            v for v in self._views if v.visibility == Visibility.PUBLIC
        ]

        if private_views:
            scroll_content.add_widget(
                self._make_section_header("My views", "my_views")
            )
            if not self._collapsed_sections.get("my_views"):
                for view in private_views:
                    scroll_content.add_widget(self._make_row(view))

        if public_views and self._manager.supports_sharing():
            scroll_content.add_widget(
                self._make_section_header("Shared views", "shared_views")
            )
            if not self._collapsed_sections.get("shared_views"):
                for view in public_views:
                    scroll_content.add_widget(self._make_row(view))

        if not self._views:
            no_views_label = AYLabel("No saved views.", dim=True)
            # Match the left indent every row's own label gets from its
            # row's contents margins + the label's own inner margin
            # (2 + 6 = 8), since this one isn't wrapped in a row.
            no_views_label.setContentsMargins(8, 4, 8, 4)
            scroll_content.add_widget(no_views_label)

        scroll_area = AYScrollArea()
        scroll_area.setWidget(scroll_content)
        scroll_area.setWidgetResizable(True)
        scroll_area.setHorizontalScrollBarPolicy(
            Qt.ScrollBarPolicy.ScrollBarAlwaysOff
        )

        # Natural content height, capped — lets the scroll area shrink
        # to fit a short list instead of always reserving max height.
        scroll_content.adjustSize()
        content_height = scroll_content.sizeHint().height()
        scroll_area.setFixedHeight(
            min(content_height, _VIEWS_LIST_MAX_HEIGHT)
        )
        return scroll_area

    # ------------------------------------------------------------------
    # Row helpers
    # ------------------------------------------------------------------

    def _style_row(self, row: AYContainer) -> None:
        """Apply the shared row inset/height to a dropdown list row.

        Uses the row's *layout* contents margins rather than the row
        widget's own ``setContentsMargins`` — ``AYFrame`` paints its own
        background using ``frameRect()`` (== ``contentsRect()``), which
        is inset by the widget's own contents margins. Insetting the
        widget itself would shrink the row's background fill by the
        same amount as its children, leaving the fill and a trailing
        action button flush against each other with no visible gap.
        The layout's margins only affect child placement, so this
        keeps the row's fill full-bleed while still inserting real
        padding around its children.
        """
        row.layout().setContentsMargins(4, 4, 8, 4)
        row.setMinimumHeight(_ROW_MIN_HEIGHT)

    def _make_separator(self) -> QFrame:
        """Return a subtle 1px divider line (outline-variant color)."""
        sep = QFrame()
        sep.setFrameShape(QFrame.Shape.HLine)
        sep.setFrameShadow(QFrame.Shadow.Plain)
        sep.setFixedHeight(1)
        sep.setStyleSheet("QFrame { border-top: 1px solid #41474d; }")
        return sep

    def _make_section_header(
        self, title: str, section_key: str
    ) -> _SectionHeader:
        """Return a clickable, collapsible section header."""
        return _SectionHeader(
            title,
            collapsed=bool(self._collapsed_sections.get(section_key)),
            on_toggle=lambda key=section_key: self._on_section_toggle(key),
        )

    def _on_section_toggle(self, section_key: str) -> None:
        """Flip a section's collapsed state and redraw the dropdown."""
        self._collapsed_sections[section_key] = (
            not self._collapsed_sections.get(section_key)
        )
        self._rebuild_menu()

    def _make_working_view_row(self, view: View) -> AYContainer:
        """Build the current-view row with an inline reset button.

        The row itself is the click target and carries the selected
        highlight — the label is plain text, not a button — so the
        label and the reset button read as one merged control, and
        clicking anywhere in the row (including the margins around the
        reset button) selects the working view. Matches the frontend,
        where ``.selected`` is applied to the whole row element.
        """
        is_current_view_is_working_view = view is not None and view.working
        is_selected = self._view_modified or is_current_view_is_working_view

        row = AYClickableRow(
            layout=AYContainer.Layout.HBox,
            variant=AYContainer.Variants.Popover,
            layout_spacing=4,
            layout_margin=0,
            hover_enabled=True,
            on_click=self._on_working_view_clicked,
        )
        self._style_row(row)
        row.set_selected(is_selected)

        label = AYLabel("Working view")
        label.setContentsMargins(6, 0, 0, 0)
        row.add_widget(label, stretch=1)

        reset_btn = AYButton(
            icon="restart_alt",
            variant=AYButton.Variants.Row_Action,
            tooltip="Reset to default",
        )
        reset_btn.clicked.connect(self._on_reset_clicked)
        row.add_widget(reset_btn)

        RowHoverTracker(row).watch(reset_btn)

        return row

    def _make_view_label(self, view: View) -> AYLabel:
        label = AYLabel(view.label or "(unnamed view)")
        label.setContentsMargins(6, 0, 0, 0)
        return label

    def _make_save_btn(self, view: View, row: AYContainer) -> AYButton:
        """Build the save button — same size as any other row action.

        It's the plain ``Row_Action`` variant, not a bespoke bigger
        one: the "modified" highlight is just its ``checked`` state
        (like the filter chips and other checkable buttons elsewhere)
        turning it primary-blue, the same mechanism, not a special
        button.
        """
        is_modified = bool(
            self._current_view
            and view.id == self._current_view.id
            and self._view_modified
        )
        btn = AYButton(
            icon="save",
            variant=AYButton.Variants.Row_Action,
            checkable=True,
            tooltip="Save view settings from current view",
        )
        btn.setChecked(is_modified)
        btn.clicked.connect(
            lambda _checked=False, v=view: self._on_view_save_clicked(v)
        )

        if not is_modified:
            sp = btn.sizePolicy()
            sp.setRetainSizeWhenHidden(True)
            btn.setSizePolicy(sp)
            btn.setVisible(False)
            row.installEventFilter(HoverReveal(btn, row))

        return btn

    def _make_edit_btn(self, view: View) -> AYButton:
        btn = AYButton(
            icon="more_horiz",
            variant=AYButton.Variants.Row_Action,
            tooltip="Edit view",
        )
        btn.clicked.connect(
            lambda _checked=False, v=view: self._on_edit_clicked(v)
        )
        return btn

    def _make_row(self, view: View) -> AYContainer:
        """Build one selectable row for *view*."""
        modified = not (
            self._current_view
            and view.id == self._current_view.id
            and not self._view_modified
        )

        row = AYClickableRow(
            layout=AYContainer.Layout.HBox,
            variant=AYContainer.Variants.Popover,
            layout_spacing=4,
            layout_margin=0,
            hover_enabled=True,
            on_click=lambda v=view: self._on_view_selected(v),
        )
        self._style_row(row)
        row.set_selected(not modified)

        row.add_widget(self._make_view_label(view), stretch=1)
        hover_watched = []

        if view.can_edit(self._current_user, self._user_access):
            if modified:
                save_btn = self._make_save_btn(view, row)
                row.add_widget(save_btn)
                hover_watched.append(save_btn)
            edit_btn = self._make_edit_btn(view)
            row.add_widget(edit_btn)
            hover_watched.append(edit_btn)

        RowHoverTracker(row).watch(*hover_watched)

        return row

    def _make_default_view_row(self) -> AYContainer:
        """Build a row for the default view."""
        return self._default_view_control.build_row()

    def _make_create_view_row(self) -> AYContainer:
        """Build the "Create new view…" row.

        Wrapped the same way as every other row (a hover-enabled
        Popover frame whose click and hover the row itself owns) so
        its look matches the rest of the dropdown instead of the plain
        Text-button gray. No :class:`RowHoverTracker` here: unlike the
        other rows, this one has no interactive child button to steal
        Enter/Leave events, so the frame drawer's own ``underMouse()``
        fallback (see ``hover_enabled``) already keeps it highlighted.
        """
        row = AYClickableRow(
            layout=AYContainer.Layout.HBox,
            variant=AYContainer.Variants.Popover,
            layout_spacing=4,
            layout_margin=0,
            hover_enabled=True,
            on_click=self._on_create_clicked,
        )
        self._style_row(row)

        label = AYLabel("Create new view…", icon="add")
        label.setContentsMargins(6, 0, 0, 0)
        row.add_widget(label, stretch=1)
        return row

    def _on_binding_error(self, stage: str, exc: BaseException) -> None:
        """Forward a :class:`ViewBindings` error via :attr:`binding_error`."""
        self.binding_error.emit(stage, str(exc) or exc.__class__.__name__)

    def _connect_modified_state_sources(self) -> None:
        """Wire widget signals that mark the view as dirty."""
        self._bindings.filter_bar.filters_changed.connect(self._mark_modified)
        self._bindings.table_view.column_state_changed.connect(self._mark_modified)
        self._bindings.table_view.header().sortIndicatorChanged.connect(
            self._mark_modified
        )

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def set_view_type(self, view_type: str) -> None:
        """Switch to a new view-type identifier."""
        if view_type == self._view_type:
            return
        self._view_type = view_type
        self._current_view = None
        self.refresh()

    def refresh(self) -> None:
        """Re-resolve the working view and drop the cached view list.

        Restoring the user's last state only needs the working view,
        which has its own endpoint. The dropdown is not on screen yet, so
        listing the rest - and each one's settings - waits until the menu
        is opened; see :meth:`_ensure_views_listed`.
        """
        self._views = []
        self._views_loaded = False

        working_view = self._ensure_working_view_exists()
        if (
            working_view is not None
            and self._current_view is None
            and not self._suppress_auto_apply
        ):
            self._apply_view(working_view, emit=True)
            return

    def _ensure_views_listed(self) -> None:
        """Fetch the view list on first use, then keep it until refresh."""
        if self._views_loaded:
            return
        try:
            self._views = list(self._manager.list_views(self._view_type))
        except Exception:
            log.exception("Failed to list views for %r", self._view_type)
            self._views = []
        self._views_loaded = True

    def current_view(self) -> View | None:
        """Return the currently active view, or ``None``."""
        return self._current_view

    def notify_view_modified(self, *_args) -> None:
        """Mark the view as modified after a UI settings change.

        Consumer code may connect custom widgets here when they affect
        :meth:`ViewBindings.capture` but are not part of the built-in
        table/filter stack.
        """
        self._mark_modified()

    def apply_default_settings(self) -> None:
        """Reset every tracked setting to the built-in AYON defaults.

        Used when neither a project nor a studio default view is
        configured, so "reset" has to fall back to what the consumer
        declared as its defaults.  Applying an empty
        :class:`ViewSettings` makes :class:`ViewBindings` fill in every
        slice from ``default_settings`` - columns, sort, filter,
        grouping, row height and the consumer's extras alike - so
        nothing carries over from the view that was active before.

        The reset lands on the *working* view: it is emitted through
        :attr:`view_applied` (so consumers can sync widgets the
        bindings do not own, such as the Group By menu) and then
        persisted, which is what makes it survive a restart.
        """
        defaults = (
            self._bindings.default_settings()
            if self._bindings.default_settings is not None
            else ViewSettings()
        )

        self._applying_view = True
        try:
            self._bindings.apply(ViewSettings())
        except Exception:
            log.exception("Failed to apply default view settings")
            return
        finally:
            self._applying_view = False
            # Nothing the user did is pending; this method saves the
            # working view itself once the widgets have settled.
            self._working_view_timer.stop()

        try:
            working_view = self._manager.get_working_view(self._view_type)
        except Exception:
            log.exception(
                "Failed to resolve working view for %r", self._view_type
            )
            working_view = None
        if working_view is None:
            working_view = View(
                label="Working",
                working=True,
                visibility=Visibility.PRIVATE,
                view_type=self._view_type,
                owner=self._current_user,
                scope=Scope.PROJECT,
            )
        working_view.settings = defaults
        working_view.loaded = True
        self._current_view = working_view
        self.setToolTip(f"View: {working_view.label}")

        # Consumers apply the slices the bindings do not own (grouping,
        # toolbar widgets) from this signal, so it has to fire before
        # the working view is captured back below.  Clearing the dirty
        # flag afterwards discards the changes those handlers report -
        # they are this reset's own writes, not the user's edits.
        self.view_applied.emit(working_view)
        self._working_view_timer.stop()
        self._clear_modified()
        self._update_working_view()

    def _capture_into(self, view: View) -> None:
        """Store the live widget state as *view*'s settings.

        Also flags the view as loaded.  Rows built from the listing
        endpoint arrive with ``loaded=False`` and empty settings, and a
        manager that fetches settings lazily reads that flag as "this
        view's settings still need fetching" - which would make it
        refetch the stored settings on save and silently discard the
        capture we just made (see ``ServerViewManager.save_view``).

        Args:
            view: The view to write the captured settings onto.
        """
        view.settings = self._bindings.capture()
        view.loaded = True

    # ------------------------------------------------------------------
    # Actions
    # ------------------------------------------------------------------

    def _on_view_selected(self, view: View) -> None:
        """Handle a view-row click in the dropdown."""
        self._apply_view(view, emit=True)
        self._close_menu()

    def _on_edit_clicked(self, view: View) -> None:
        """Open the editor for an existing view."""
        self._close_menu()
        # A row from the listing lacks the view's access grants; the
        # editor must start from the stored ones or saving wipes them.
        loaded_view = self._manager.load_view(view)
        if not loaded_view.loaded:
            # On failure to load, just stop here. Manager would've already
            # emitted the error
            return
        view_copy = View.from_payload(loaded_view.to_payload())
        self._capture_into(view_copy)
        editor = self._create_editor(view_copy)
        if editor.exec() != QDialog.DialogCode.Accepted:
            return
        with self._suspend_auto_apply():
            if editor.delete_requested():
                self._delete_view(editor.get_view())
                return
            saved = self._save_view(editor.get_view())
        if saved is not None and (
            self._current_view is not None
            and self._current_view.id == saved.id
        ):
            self._apply_view(saved, emit=True)

    def _on_create_clicked(self) -> None:
        """Open the editor for a new view, then save it."""
        self._close_menu()
        new_view = View(view_type=self._view_type)
        self._capture_into(new_view)
        editor = self._create_editor(new_view)
        if editor.exec() != QDialog.DialogCode.Accepted:
            return
        with self._suspend_auto_apply():
            if editor.delete_requested():
                self._delete_view(editor.get_view())
                return
            saved = self._save_view(editor.get_view())
        if saved is not None:
            self._apply_view(saved, emit=True)

    def _create_editor(self, view: View) -> AYViewEditor:
        """Build the metadata editor dialog for *view*."""
        allow_sharing = self._manager.supports_sharing()
        usernames_and_groups = (
            self._get_usernames_and_groups() if allow_sharing else None
        )
        return AYViewEditor(
            view,
            current_user=self._current_user,
            allow_studio_scope=self._allow_studio_scope,
            current_project=self._current_project_name(),
            usernames_and_groups=usernames_and_groups,
            allow_sharing=allow_sharing,
            parent=self,
        )

    def _delete_view(self, view: View) -> None:
        if not view.id:
            return
        if self._current_view is not None and self._current_view.id == view.id:
            self._current_view = None
            working = self._manager.get_working_view(self._view_type)
            if working:
                self._current_view = working
        try:
            self._manager.delete_view(view.id)
        except Exception:
            log.exception("Failed to delete view %r", view.id)
            return
        self.view_deleted.emit(view.id)
        self._apply_view(self._current_view, emit=True)

    def _on_reset_clicked(self) -> None:
        """Reset the current view to the default view for the current scope."""
        ctrl = self._default_view_control

        if ctrl.project_default_view:
            ctrl.load_project_default_view()
            return

        if ctrl.studio_default_view:
            ctrl.load_studio_default_view()
            return

        if ctrl.make_default_view_settings:
            ctrl.make_default_view_settings()
            return

        self.emit_default_view_message(
            "No default view configured.",
            False,
        )

    def _on_manager_changed(self, view_type: str) -> None:
        """Refresh when the manager signals a change for our type."""
        if view_type == self._view_type:
            self.refresh()

    def _on_project_changed(self, project_name: str) -> None:
        """Reset state and refresh when the manager switches project.
        clear active view belongs to the old project so the new
        project's working view can be auto-applied.
        """
        self._current_view = None
        self._clear_modified()
        self.refresh()
        self._update_enabled_for_project()

    def _update_enabled_for_project(self) -> None:
        """Enable the Views button only while a project is active.

        With no project there is nothing sensible for a view to apply
        to, so opening the dropdown wouldn't do anything useful — keep
        it disabled instead. Called after :meth:`refresh`, since that
        may apply a working view and set its own "View: ..." tooltip;
        this must have the final say when there's no project.
        """
        has_project = bool(self._current_project_name())
        self.setEnabled(has_project)
        if not has_project:
            self.setToolTip("Views (select a project first)")

    def _get_usernames_and_groups(self) -> dict[str, list]:
        """Fetch active project users and return unique usernames."""
        project_name = getattr(self._manager, "project_name", "")
        if not project_name:
            return {"users": [], "groups": []}
        users = []
        groups = []
        try:
            users = ayon_api.get_users(project_name=project_name) or []
        except Exception:  # noqa: BLE001
            log.exception("Failed to fetch users for project %r",
                          project_name)

        try:
            group_data = ayon_api.get(f"/accessGroups/{project_name}").data
            groups = [group.get('name') for group in group_data]
        except Exception:
            log.exception("Failed to fetch project groups")

        users_data: list[dict[str, str]] = []
        seen: set[str] = set()
        for user in users:
            if not isinstance(user, dict):
                continue
            if not bool(user.get("active", False)):
                continue
            name = str(user.get("name") or "").strip()
            if not name or name in seen or name == self._current_user:
                continue

            full_name = user["attrib"].get("fullName") or name

            seen.add(name)
            users_data.append({"name": name, "fullName": full_name})
        return {"users": users_data, "groups": groups}

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    def _close_menu(self) -> None:
        """Close the dropdown popup if currently open."""
        try:
            self._dropdown.close()
        except Exception:
            pass

    def refresh_dropdown_size(self) -> None:
        """Resize the dropdown popup to fit its current content.

        Call after any change to the dropdown's contents (a full
        rebuild, or swapping a single row/pill's children in place):
        rebuilding a widget's children leaves stale cached size hints
        on its ancestors, so a plain ``adjustSize()`` alone can settle
        on a size that no longer fits — e.g. clipping the label text on
        a default-view pill after it switches between its "unset" and
        "active" (wider) look. Invalidating the layout chain first
        forces every ancestor to recompute before the popup resizes.
        """
        layout = self._dropdown_layout
        if layout is not None:
            layout.invalidate()
            layout.activate()
        self._dropdown.updateGeometry()
        self._dropdown.adjustSize()

    def emit_default_view_message(self, message: str, success: bool) -> None:
        """Emit default-view feedback for host UIs.

        Hosts can map this to the same toast presentation used by loader
        action results.
        """
        self.default_view_message.emit(message, success)

    def _current_project_name(self) -> str:
        """Return the manager's current project name when available."""
        project_name = getattr(self._manager, "project_name", "")
        return str(project_name or "")

    def _ensure_working_view_exists(self) -> View | None:
        """Return the current working view, creating one when missing."""
        if self._ensuring_working_view or self._current_view is not None:
            return None

        try:
            existing = self._manager.get_working_view(self._view_type)
        except Exception:
            log.exception(
                "Failed to resolve working view for %r", self._view_type
            )
            existing = None
        if existing is not None:
            return existing

        project_name = getattr(self._manager, "project_name", None)
        if project_name is not None and not str(project_name or ""):
            return None

        working_view = View(
            label="Working",
            settings=self._bindings.capture(),
            working=True,
            visibility=Visibility.PRIVATE,
            view_type=self._view_type,
            owner=self._current_user,
            scope=Scope.PROJECT
        )

        self._ensuring_working_view = True
        try:
            with self._suspend_auto_apply():
                self._manager.set_working_view(working_view)
        except Exception:
            log.exception(
                "Failed to create initial working view for %r",
                self._view_type,
            )
            return None
        finally:
            self._ensuring_working_view = False

        refreshed_working = self._manager.get_working_view(self._view_type)
        if refreshed_working is not None:
            return refreshed_working
        return working_view

    def _mark_modified(self, *_args) -> None:
        """Set the dirty flag on the first UI change after a view is applied.
            and cleared by applying, saving, or resetting a view.
        """
        if self._applying_view:
            # Our own writes bouncing back off the widgets.
            return

        self._working_view_timer.start()

        if self._view_modified:
            return

        self._view_modified = True

        if self._current_view and not self._current_view.working:
            self.set_variant(AYButton.Variants.Filled)

        if self._current_view:
            view_name = self._current_view.label or self._current_view.id
            self.view_modified.emit(view_name, True)

    def _apply_view(self, view: View, emit: bool) -> None:
        """Apply *view* to the bindings and clear the dirty flag.

        Default views are applied onto the working-view context, so edits
        continue against the working view.
        """
        is_default_view = (
            not view.working
            and view.label == DEFAULT_VIEW_LABEL
        )

        target_view = view
        if is_default_view:
            try:
                working_view = self._manager.get_working_view(self._view_type)
            except Exception:
                log.exception(
                    "Failed to resolve working view for %r", self._view_type
                )
                return
            target_view = working_view
            self._close_menu()

        # A view picked from the dropdown carries only listing metadata;
        # this is where its settings are actually needed.
        view = self._manager.load_view(view)
        if target_view is not None and target_view.id == view.id:
            target_view = view
        self._current_view = target_view

        self._applying_view = True
        try:
            self._bindings.apply(view.settings)
        except Exception:
            log.exception("Failed to apply view %r", view.id)
            return
        finally:
            self._applying_view = False
            # Nothing was modified, so drop any save this apply queued.
            self._working_view_timer.stop()

        self.setToolTip(f"View: {self._current_view.label}")
        self._clear_modified()

        if emit:
            self.view_applied.emit(view)

    def _on_view_save_clicked(self, view: View) -> None:
        self._close_menu()
        self._capture_into(view)
        with self._suspend_auto_apply():
            saved = self._save_view(view)
        if saved is not None:
            self._apply_view(saved, emit=True)

    def _clear_modified(self) -> None:
        """Clear the dirty flag and restore the default button variant."""
        if not self._view_modified:
            return
        self._view_modified = False
        self.set_variant(AYButton.Variants.Surface)
        if self._current_view:
            view_name = self._current_view.label or self._current_view.id
            self.view_modified.emit(view_name, False)

    def _save_view(self, view: View) -> View | None:
        """
        This called when new view is created or existing view is updated.
        Persist *view* and return the manager's response."""
        try:
            saved = self._manager.save_view(view)
        except Exception:
            log.exception("Failed to save view %r", view.label)
            return None
        self.view_saved.emit(saved)
        return saved

    def _on_working_view_clicked(self, _checked: bool = False) -> None:
        try:
            working_view = self._manager.get_working_view(self._view_type)
        except Exception:
            log.exception(
                "Failed to resolve working view for %r", self._view_type
            )
            return

        if not working_view:
            return

        self._apply_view(working_view, emit=True)

        # Change views icon state to normal
        self.set_variant(AYButton.Variants.Surface)
        self._close_menu()

    def hideEvent(self, event) -> None:
        """Flush a pending working-view save before going away.

        The debounce would otherwise drop the last few hundred
        milliseconds of edits when the window closes.
        """
        if self._working_view_timer.isActive():
            self._working_view_timer.stop()
            self._update_working_view()
        super().hideEvent(event)

    def _update_working_view(self) -> None:
        """Persist the live widget state onto the working view."""
        # The applied view is normally the working view already, so going
        # back to the server for it would just re-read what we hold.
        working_view = self._current_view
        if working_view is None or not working_view.working:
            try:
                working_view = self._manager.get_working_view(self._view_type)
            except Exception:
                log.exception(
                    "Failed to resolve working view for %r", self._view_type
                )
                return
        if working_view is None:
            return
        self._capture_into(working_view)
        with self._suspend_auto_apply():
            self._manager.set_working_view(working_view)

    def get_default_views(self):
        """Return the current default views from the default view control."""
        return (
            self._default_view_control.studio_default_view,
            self._default_view_control.project_default_view,
        )

    def _suspend_auto_apply(self) -> "_SuspendAutoApply":
        """Return a context manager that suppresses the auto-apply branch
        of :meth:`refresh` while a save is in flight.
        """
        return _SuspendAutoApply(self)

apply_default_settings()

Reset every tracked setting to the built-in AYON defaults.

Used when neither a project nor a studio default view is configured, so "reset" has to fall back to what the consumer declared as its defaults. Applying an empty :class:ViewSettings makes :class:ViewBindings fill in every slice from default_settings - columns, sort, filter, grouping, row height and the consumer's extras alike - so nothing carries over from the view that was active before.

The reset lands on the working view: it is emitted through :attr:view_applied (so consumers can sync widgets the bindings do not own, such as the Group By menu) and then persisted, which is what makes it survive a restart.

Source code in client/ayon_core/ui/components/views/view_selector.py
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
def apply_default_settings(self) -> None:
    """Reset every tracked setting to the built-in AYON defaults.

    Used when neither a project nor a studio default view is
    configured, so "reset" has to fall back to what the consumer
    declared as its defaults.  Applying an empty
    :class:`ViewSettings` makes :class:`ViewBindings` fill in every
    slice from ``default_settings`` - columns, sort, filter,
    grouping, row height and the consumer's extras alike - so
    nothing carries over from the view that was active before.

    The reset lands on the *working* view: it is emitted through
    :attr:`view_applied` (so consumers can sync widgets the
    bindings do not own, such as the Group By menu) and then
    persisted, which is what makes it survive a restart.
    """
    defaults = (
        self._bindings.default_settings()
        if self._bindings.default_settings is not None
        else ViewSettings()
    )

    self._applying_view = True
    try:
        self._bindings.apply(ViewSettings())
    except Exception:
        log.exception("Failed to apply default view settings")
        return
    finally:
        self._applying_view = False
        # Nothing the user did is pending; this method saves the
        # working view itself once the widgets have settled.
        self._working_view_timer.stop()

    try:
        working_view = self._manager.get_working_view(self._view_type)
    except Exception:
        log.exception(
            "Failed to resolve working view for %r", self._view_type
        )
        working_view = None
    if working_view is None:
        working_view = View(
            label="Working",
            working=True,
            visibility=Visibility.PRIVATE,
            view_type=self._view_type,
            owner=self._current_user,
            scope=Scope.PROJECT,
        )
    working_view.settings = defaults
    working_view.loaded = True
    self._current_view = working_view
    self.setToolTip(f"View: {working_view.label}")

    # Consumers apply the slices the bindings do not own (grouping,
    # toolbar widgets) from this signal, so it has to fire before
    # the working view is captured back below.  Clearing the dirty
    # flag afterwards discards the changes those handlers report -
    # they are this reset's own writes, not the user's edits.
    self.view_applied.emit(working_view)
    self._working_view_timer.stop()
    self._clear_modified()
    self._update_working_view()

current_view()

Return the currently active view, or None.

Source code in client/ayon_core/ui/components/views/view_selector.py
632
633
634
def current_view(self) -> View | None:
    """Return the currently active view, or ``None``."""
    return self._current_view

emit_default_view_message(message, success)

Emit default-view feedback for host UIs.

Hosts can map this to the same toast presentation used by loader action results.

Source code in client/ayon_core/ui/components/views/view_selector.py
925
926
927
928
929
930
931
def emit_default_view_message(self, message: str, success: bool) -> None:
    """Emit default-view feedback for host UIs.

    Hosts can map this to the same toast presentation used by loader
    action results.
    """
    self.default_view_message.emit(message, success)

get_default_views()

Return the current default views from the default view control.

Source code in client/ayon_core/ui/components/views/view_selector.py
1132
1133
1134
1135
1136
1137
def get_default_views(self):
    """Return the current default views from the default view control."""
    return (
        self._default_view_control.studio_default_view,
        self._default_view_control.project_default_view,
    )

hideEvent(event)

Flush a pending working-view save before going away.

The debounce would otherwise drop the last few hundred milliseconds of edits when the window closes.

Source code in client/ayon_core/ui/components/views/view_selector.py
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
def hideEvent(self, event) -> None:
    """Flush a pending working-view save before going away.

    The debounce would otherwise drop the last few hundred
    milliseconds of edits when the window closes.
    """
    if self._working_view_timer.isActive():
        self._working_view_timer.stop()
        self._update_working_view()
    super().hideEvent(event)

notify_view_modified(*_args)

Mark the view as modified after a UI settings change.

Consumer code may connect custom widgets here when they affect :meth:ViewBindings.capture but are not part of the built-in table/filter stack.

Source code in client/ayon_core/ui/components/views/view_selector.py
636
637
638
639
640
641
642
643
def notify_view_modified(self, *_args) -> None:
    """Mark the view as modified after a UI settings change.

    Consumer code may connect custom widgets here when they affect
    :meth:`ViewBindings.capture` but are not part of the built-in
    table/filter stack.
    """
    self._mark_modified()

refresh()

Re-resolve the working view and drop the cached view list.

Restoring the user's last state only needs the working view, which has its own endpoint. The dropdown is not on screen yet, so listing the rest - and each one's settings - waits until the menu is opened; see :meth:_ensure_views_listed.

Source code in client/ayon_core/ui/components/views/view_selector.py
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
def refresh(self) -> None:
    """Re-resolve the working view and drop the cached view list.

    Restoring the user's last state only needs the working view,
    which has its own endpoint. The dropdown is not on screen yet, so
    listing the rest - and each one's settings - waits until the menu
    is opened; see :meth:`_ensure_views_listed`.
    """
    self._views = []
    self._views_loaded = False

    working_view = self._ensure_working_view_exists()
    if (
        working_view is not None
        and self._current_view is None
        and not self._suppress_auto_apply
    ):
        self._apply_view(working_view, emit=True)
        return

refresh_dropdown_size()

Resize the dropdown popup to fit its current content.

Call after any change to the dropdown's contents (a full rebuild, or swapping a single row/pill's children in place): rebuilding a widget's children leaves stale cached size hints on its ancestors, so a plain adjustSize() alone can settle on a size that no longer fits — e.g. clipping the label text on a default-view pill after it switches between its "unset" and "active" (wider) look. Invalidating the layout chain first forces every ancestor to recompute before the popup resizes.

Source code in client/ayon_core/ui/components/views/view_selector.py
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
def refresh_dropdown_size(self) -> None:
    """Resize the dropdown popup to fit its current content.

    Call after any change to the dropdown's contents (a full
    rebuild, or swapping a single row/pill's children in place):
    rebuilding a widget's children leaves stale cached size hints
    on its ancestors, so a plain ``adjustSize()`` alone can settle
    on a size that no longer fits — e.g. clipping the label text on
    a default-view pill after it switches between its "unset" and
    "active" (wider) look. Invalidating the layout chain first
    forces every ancestor to recompute before the popup resizes.
    """
    layout = self._dropdown_layout
    if layout is not None:
        layout.invalidate()
        layout.activate()
    self._dropdown.updateGeometry()
    self._dropdown.adjustSize()

set_view_type(view_type)

Switch to a new view-type identifier.

Source code in client/ayon_core/ui/components/views/view_selector.py
593
594
595
596
597
598
599
def set_view_type(self, view_type: str) -> None:
    """Switch to a new view-type identifier."""
    if view_type == self._view_type:
        return
    self._view_type = view_type
    self._current_view = None
    self.refresh()

ColumnState dataclass

State of a single column inside a View.

Attributes:

Name Type Description
name str

Column key, matches :attr:TableColumn.key.

visible bool

Whether the column is currently shown.

pinned bool

Whether the column is pinned to the left (Phase 3 polish; stored but not visually frozen in Phase 1).

width int | None

Pixel width override, or None to use the column's default / auto width. Note that :class:~ayon_core.ui.components.table_model.TableColumn uses 0 to mean auto; the mapping is handled by PaginatedTableModel.apply_settings.

Source code in client/ayon_core/ui/components/views/data_models.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
@dataclass
class ColumnState:
    """State of a single column inside a View.

    Attributes:
        name: Column key, matches :attr:`TableColumn.key`.
        visible: Whether the column is currently shown.
        pinned: Whether the column is pinned to the left (Phase 3 polish;
            stored but not visually frozen in Phase 1).
        width: Pixel width override, or ``None`` to use the column's
            default / auto width.  Note that
            :class:`~ayon_core.ui.components.table_model.TableColumn`
            uses ``0`` to mean *auto*; the mapping is handled by
            ``PaginatedTableModel.apply_settings``.
    """

    name: str
    visible: bool = True
    pinned: bool = False
    width: int | None = None

    @classmethod
    def from_payload(cls, payload: dict[str, Any]) -> "ColumnState":
        """Build a ColumnState from a single payload column dict.

        Args:
            payload: Dict like ``{"name": "...", "visible": True, ...}``.

        Returns:
            A new ColumnState instance.
        """
        width = payload.get("width")
        if width is not None:
            try:
                width = int(width)
            except (TypeError, ValueError):
                width = None
        return cls(
            name=str(payload.get("name", "")),
            visible=bool(payload.get("visible", True)),
            pinned=bool(payload.get("pinned", False)),
            width=width,
        )

    def to_payload(self) -> dict[str, Any]:
        """Serialise this column state to the payload format.

        Returns:
            A new dict with payload-friendly keys.
        """
        out: dict[str, Any] = {
            "name": self.name,
            "visible": self.visible,
            "pinned": self.pinned,
        }
        if self.width is not None:
            out["width"] = self.width
        return out

from_payload(payload) classmethod

Build a ColumnState from a single payload column dict.

Parameters:

Name Type Description Default
payload dict[str, Any]

Dict like {"name": "...", "visible": True, ...}.

required

Returns:

Type Description
'ColumnState'

A new ColumnState instance.

Source code in client/ayon_core/ui/components/views/data_models.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "ColumnState":
    """Build a ColumnState from a single payload column dict.

    Args:
        payload: Dict like ``{"name": "...", "visible": True, ...}``.

    Returns:
        A new ColumnState instance.
    """
    width = payload.get("width")
    if width is not None:
        try:
            width = int(width)
        except (TypeError, ValueError):
            width = None
    return cls(
        name=str(payload.get("name", "")),
        visible=bool(payload.get("visible", True)),
        pinned=bool(payload.get("pinned", False)),
        width=width,
    )

to_payload()

Serialise this column state to the payload format.

Returns:

Type Description
dict[str, Any]

A new dict with payload-friendly keys.

Source code in client/ayon_core/ui/components/views/data_models.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def to_payload(self) -> dict[str, Any]:
    """Serialise this column state to the payload format.

    Returns:
        A new dict with payload-friendly keys.
    """
    out: dict[str, Any] = {
        "name": self.name,
        "visible": self.visible,
        "pinned": self.pinned,
    }
    if self.width is not None:
        out["width"] = self.width
    return out

FilterDef dataclass

Server-side filter configuration captured in a View.

The condition dicts are opaque to ayon-core: they are passed through to the filter proxy untouched. The proxy converts them to :class:FilterCriterion via :meth:FilterCriterion.from_def / :meth:to_def.

Attributes:

Name Type Description
conditions list[dict[str, Any]]

List of condition dicts. Each dict has at least a key and values field.

operator str

How conditions combine — "and" or "or".

Source code in client/ayon_core/ui/components/views/data_models.py
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
@dataclass
class FilterDef:
    """Server-side filter configuration captured in a View.

    The condition dicts are opaque to ``ayon-core``: they are passed
    through to the filter proxy untouched.  The proxy converts them
    to :class:`FilterCriterion` via
    :meth:`FilterCriterion.from_def` / :meth:`to_def`.

    Attributes:
        conditions: List of condition dicts.  Each dict has at least a
            ``key`` and ``values`` field.
        operator: How conditions combine — ``"and"`` or ``"or"``.
    """

    conditions: list[dict[str, Any]] = field(default_factory=list)
    operator: str = "and"

    @classmethod
    def from_payload(cls, payload: dict[str, Any] | None) -> "FilterDef":
        """Build a FilterDef from the payload's ``filter`` dict.

        Args:
            payload: Filter sub-dict, or ``None`` for an empty filter.

        Returns:
            A new FilterDef.
        """
        if not payload:
            return cls()
        raw_conds = payload.get("conditions") or []
        operator = str(payload.get("operator", "and")).lower()
        if operator not in ("and", "or"):
            operator = "and"
        return cls(
            conditions=[
                copy.deepcopy(c) for c in raw_conds if isinstance(c, dict)
            ],
            operator=operator,
        )

    def to_payload(self) -> dict[str, Any]:
        """Serialise this filter to a payload dict.

        Returns:
            A dict with ``conditions`` and ``operator`` keys.
        """
        return {
            "conditions": [copy.deepcopy(c) for c in self.conditions],
            "operator": self.operator,
        }

    def is_empty(self) -> bool:
        """Return True when there are no filter conditions.

        Returns:
            True when there are no conditions configured.
        """
        return not self.conditions

from_payload(payload) classmethod

Build a FilterDef from the payload's filter dict.

Parameters:

Name Type Description Default
payload dict[str, Any] | None

Filter sub-dict, or None for an empty filter.

required

Returns:

Type Description
'FilterDef'

A new FilterDef.

Source code in client/ayon_core/ui/components/views/data_models.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
@classmethod
def from_payload(cls, payload: dict[str, Any] | None) -> "FilterDef":
    """Build a FilterDef from the payload's ``filter`` dict.

    Args:
        payload: Filter sub-dict, or ``None`` for an empty filter.

    Returns:
        A new FilterDef.
    """
    if not payload:
        return cls()
    raw_conds = payload.get("conditions") or []
    operator = str(payload.get("operator", "and")).lower()
    if operator not in ("and", "or"):
        operator = "and"
    return cls(
        conditions=[
            copy.deepcopy(c) for c in raw_conds if isinstance(c, dict)
        ],
        operator=operator,
    )

is_empty()

Return True when there are no filter conditions.

Returns:

Type Description
bool

True when there are no conditions configured.

Source code in client/ayon_core/ui/components/views/data_models.py
219
220
221
222
223
224
225
def is_empty(self) -> bool:
    """Return True when there are no filter conditions.

    Returns:
        True when there are no conditions configured.
    """
    return not self.conditions

to_payload()

Serialise this filter to a payload dict.

Returns:

Type Description
dict[str, Any]

A dict with conditions and operator keys.

Source code in client/ayon_core/ui/components/views/data_models.py
208
209
210
211
212
213
214
215
216
217
def to_payload(self) -> dict[str, Any]:
    """Serialise this filter to a payload dict.

    Returns:
        A dict with ``conditions`` and ``operator`` keys.
    """
    return {
        "conditions": [copy.deepcopy(c) for c in self.conditions],
        "operator": self.operator,
    }

GroupingDef dataclass

Grouping configuration captured in a View.

Attributes:

Name Type Description
group_by str | None

Column key to group rows by, or None for no grouping.

group_sort_desc bool

Whether the group ordering is descending.

show_empty_groups bool

Whether groups with no visible rows are rendered or hidden.

Source code in client/ayon_core/ui/components/views/data_models.py
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
@dataclass
class GroupingDef:
    """Grouping configuration captured in a View.

    Attributes:
        group_by: Column key to group rows by, or ``None`` for no
            grouping.
        group_sort_desc: Whether the group ordering is descending.
        show_empty_groups: Whether groups with no visible rows are
            rendered or hidden.
    """

    group_by: str | None = None
    group_sort_desc: bool = False
    show_empty_groups: bool = False

    def is_empty(self) -> bool:
        """Return True when no grouping is configured.

        Returns:
            True when ``group_by`` is ``None``.
        """
        return self.group_by is None

is_empty()

Return True when no grouping is configured.

Returns:

Type Description
bool

True when group_by is None.

Source code in client/ayon_core/ui/components/views/data_models.py
244
245
246
247
248
249
250
def is_empty(self) -> bool:
    """Return True when no grouping is configured.

    Returns:
        True when ``group_by`` is ``None``.
    """
    return self.group_by is None

InMemoryViewManager

Bases: ViewManager

Lightweight in-process implementation used by tests / demos.

Stores views in a dict keyed by view.id. Useful when the consumer app has not yet wired the real server backend, or when running the standalone tester harness.

Source code in client/ayon_core/ui/components/views/view_manager.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
class InMemoryViewManager(ViewManager):
    """Lightweight in-process implementation used by tests / demos.

    Stores views in a dict keyed by ``view.id``.  Useful when the
    consumer app has not yet wired the real server backend, or when
    running the standalone tester harness.
    """

    def __init__(
        self,
        views: list[View] | None = None,
        parent: QObject | None = None,
    ) -> None:
        """Seed the manager with an optional initial set of views.

        Args:
            views: Initial views to register.  Each view must have a
                non-empty :attr:`View.id`; one will be assigned if
                missing.
            parent: Optional parent QObject.
        """
        super().__init__(parent)
        self._views: dict[str, View] = {}
        for view in views or []:
            if not view.id:
                view.id = self._new_id()
            self._views[view.id] = view

    # -- ViewManager API ---------------------------------------------------

    def list_views(self, view_type: str) -> list[View]:
        """Return views matching *view_type*, sorted for display.

        Args:
            view_type: The view-type identifier.

        Returns:
            Sorted list of views.
        """
        matching = [
            v for v in self._views.values() if v.view_type == view_type
        ]
        matching.sort(key=lambda v: (v.position, v.label))
        return matching

    def save_view(self, view: View) -> View:
        """Insert or replace *view* in the in-memory store.

        Args:
            view: The view to save.  An id is assigned if missing.

        Returns:
            The same view instance (with assigned id).
        """
        if not view.id:
            view.id = self._new_id()
        self._views[view.id] = view
        self.view_saved.emit(view.id)
        self.views_changed.emit(view.view_type)
        return view

    def delete_view(self, view_id: str) -> None:
        """Remove *view_id* from the store, if present.

        Args:
            view_id: Identifier of the view to remove.  Missing ids
                are silently ignored after a debug log entry.
        """
        existing = self._views.pop(view_id, None)
        if existing is None:
            log.debug("delete_view: no view with id %r", view_id)
            return
        self.view_deleted.emit(view_id)
        self.views_changed.emit(existing.view_type)

    # -- helpers -----------------------------------------------------------

    @staticmethod
    def _new_id() -> str:
        """Return a new unique view identifier.

        Returns:
            A short uuid4 hex string.
        """
        return uuid.uuid4().hex

    def clear(self) -> None:
        """Remove all stored views (intended for tests)."""
        types = {v.view_type for v in self._views.values()}
        self._views.clear()
        for view_type in types:
            self.views_changed.emit(view_type)

    def all_views(self) -> list[View]:
        """Return every stored view across all view types.

        Returns:
            A snapshot list of all stored views.
        """
        return list(self._views.values())

__init__(views=None, parent=None)

Seed the manager with an optional initial set of views.

Parameters:

Name Type Description Default
views list[View] | None

Initial views to register. Each view must have a non-empty :attr:View.id; one will be assigned if missing.

None
parent QObject | None

Optional parent QObject.

None
Source code in client/ayon_core/ui/components/views/view_manager.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def __init__(
    self,
    views: list[View] | None = None,
    parent: QObject | None = None,
) -> None:
    """Seed the manager with an optional initial set of views.

    Args:
        views: Initial views to register.  Each view must have a
            non-empty :attr:`View.id`; one will be assigned if
            missing.
        parent: Optional parent QObject.
    """
    super().__init__(parent)
    self._views: dict[str, View] = {}
    for view in views or []:
        if not view.id:
            view.id = self._new_id()
        self._views[view.id] = view

all_views()

Return every stored view across all view types.

Returns:

Type Description
list[View]

A snapshot list of all stored views.

Source code in client/ayon_core/ui/components/views/view_manager.py
294
295
296
297
298
299
300
def all_views(self) -> list[View]:
    """Return every stored view across all view types.

    Returns:
        A snapshot list of all stored views.
    """
    return list(self._views.values())

clear()

Remove all stored views (intended for tests).

Source code in client/ayon_core/ui/components/views/view_manager.py
287
288
289
290
291
292
def clear(self) -> None:
    """Remove all stored views (intended for tests)."""
    types = {v.view_type for v in self._views.values()}
    self._views.clear()
    for view_type in types:
        self.views_changed.emit(view_type)

delete_view(view_id)

Remove view_id from the store, if present.

Parameters:

Name Type Description Default
view_id str

Identifier of the view to remove. Missing ids are silently ignored after a debug log entry.

required
Source code in client/ayon_core/ui/components/views/view_manager.py
262
263
264
265
266
267
268
269
270
271
272
273
274
def delete_view(self, view_id: str) -> None:
    """Remove *view_id* from the store, if present.

    Args:
        view_id: Identifier of the view to remove.  Missing ids
            are silently ignored after a debug log entry.
    """
    existing = self._views.pop(view_id, None)
    if existing is None:
        log.debug("delete_view: no view with id %r", view_id)
        return
    self.view_deleted.emit(view_id)
    self.views_changed.emit(existing.view_type)

list_views(view_type)

Return views matching view_type, sorted for display.

Parameters:

Name Type Description Default
view_type str

The view-type identifier.

required

Returns:

Type Description
list[View]

Sorted list of views.

Source code in client/ayon_core/ui/components/views/view_manager.py
231
232
233
234
235
236
237
238
239
240
241
242
243
244
def list_views(self, view_type: str) -> list[View]:
    """Return views matching *view_type*, sorted for display.

    Args:
        view_type: The view-type identifier.

    Returns:
        Sorted list of views.
    """
    matching = [
        v for v in self._views.values() if v.view_type == view_type
    ]
    matching.sort(key=lambda v: (v.position, v.label))
    return matching

save_view(view)

Insert or replace view in the in-memory store.

Parameters:

Name Type Description Default
view View

The view to save. An id is assigned if missing.

required

Returns:

Type Description
View

The same view instance (with assigned id).

Source code in client/ayon_core/ui/components/views/view_manager.py
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
def save_view(self, view: View) -> View:
    """Insert or replace *view* in the in-memory store.

    Args:
        view: The view to save.  An id is assigned if missing.

    Returns:
        The same view instance (with assigned id).
    """
    if not view.id:
        view.id = self._new_id()
    self._views[view.id] = view
    self.view_saved.emit(view.id)
    self.views_changed.emit(view.view_type)
    return view

Scope

Bases: str, Enum

The level at which a view is stored on the server.

Source code in client/ayon_core/ui/components/views/data_models.py
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
class Scope(str, Enum):
    """The level at which a view is stored on the server."""

    PROJECT = "project"
    STUDIO = "studio"

    @classmethod
    def _missing_(cls, value: Any) -> "Scope":
        """Fall back to ``PROJECT`` for unknown values.

        Args:
            value: Raw value found in the payload.

        Returns:
            The matching enum member, or ``PROJECT`` if unknown.
        """
        log.warning("Unknown Scope value %r, defaulting to PROJECT", value)
        return cls.PROJECT

ServerViewManager

Bases: ViewManager

ViewManager backed by AYON server REST endpoints.

Endpoints (project-scoped, all take ?project_name=<p>):

  • GET /api/views/{view_type}
  • GET /api/views/{view_type}/working
  • POST /api/views/{view_type}
  • PATCH /api/views/{view_type}/{view_id}
  • POST /api/addons/powerpack/{version}/views/{view_type}/{id}/share
  • DELETE /api/views/{view_type}/{view_id}

Attributes:

Name Type Description
project_name str

Name of the project all calls are scoped to.

Source code in client/ayon_core/ui/components/views/server_view_manager.py
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
class ServerViewManager(ViewManager):
    """ViewManager backed by AYON server REST endpoints.

    Endpoints (project-scoped, all take ``?project_name=<p>``):

    * ``GET    /api/views/{view_type}``
    * ``GET    /api/views/{view_type}/working``
    * ``POST   /api/views/{view_type}``
    * ``PATCH  /api/views/{view_type}/{view_id}``
    * ``POST   /api/addons/powerpack/{version}/views/{view_type}/{id}/share``
    * ``DELETE /api/views/{view_type}/{view_id}``

    Attributes:
        project_name: Name of the project all calls are scoped to.
    """

    def __init__(
            self,
            project_name: str,
            parent: QObject | None = None,
    ) -> None:
        """Initialise the manager.

        Args:
            project_name: Initial project the manager is scoped to.
                May be empty when the loader has no project yet.
            parent: Optional parent QObject.
        """
        super().__init__(parent=parent)
        self._project_name = project_name
        # view_type -> sorted list of views (per-type list cache)
        self._cache: dict[str, list[View]] = {}
        # view_id -> (view_type, scope) (survives list_views; used by
        # delete_view so it doesn't require a populated per-type cache).
        self._id_to_view_attributes: dict[str, tuple[str, Scope]] = {}
        self._powerpack_version: str | None = None
        self._powerpack_resolved: bool = False

    # ------------------------------------------------------------------
    # Project scope
    # ------------------------------------------------------------------

    @property
    def project_name(self) -> str:
        """Current project the manager is scoped to."""
        return self._project_name

    def set_project(self, project_name: str) -> None:
        """Rebind the manager to a different project.

        Clears both caches and emits :attr:`project_changed` with the
        new project name so listeners can refresh.

        Args:
            project_name: Target project name.  No-op when the same as
                the current project.
        """
        if project_name == self._project_name:
            return
        self._project_name = project_name
        self._id_to_view_attributes.clear()
        self._cache.clear()
        self.project_changed.emit(project_name)

    # ------------------------------------------------------------------
    # ViewManager API
    # ------------------------------------------------------------------

    def get_working_view(self, view_type: str) -> View | None:
        """Return the working view for *view_type* using the direct endpoint.

        Uses ``GET /api/views/{view_type}/working`` for faster lookup than
        scanning ``list_views``.

        Args:
            view_type: View-type identifier.

        Returns:
            Parsed working :class:`View`, or ``None`` when not found.
        """
        if not self._project_name:
            return None

        try:
            resp = ayon_api.get(
                f"views/{view_type}/working",
                project_name=self._project_name,
            )
        except Exception as exc:  # noqa: BLE001
            log.exception("Failed to fetch working view for %s", view_type)
            self.error.emit(f"Failed to fetch working view: {exc}")
            return None

        # No working view yet (404) - the error body is not a view, parsing
        #   it would give a view without id and view type which then fails
        #   to save.
        if resp.status_code != 200 or not resp.data:
            return None
        view = View.from_payload(resp.data)
        if not view.view_type:
            view.view_type = view_type
        return view

    def get_default_project_view(self, view_type: str) -> View | None:
        """Return project default view."""
        if not self._project_name:
            return None

        try:
            resp = ayon_api.get(
                f"views/{view_type}/base",
                project_name=self._project_name,
            )
        except Exception as exc:  # noqa: BLE001
            log.exception(
                "Failed to fetch project default view for %s", view_type
            )
            self.error.emit(f"Failed to fetch project default view: {exc}")
            return None
        if resp.status_code != 200:
            return None
        view = View.from_payload(resp.data)
        self._id_to_view_attributes[view.id] = (view_type, view.scope)
        return view

    def get_default_studio_view(self, view_type: str) -> View | None:
        """Return studio default view."""
        try:
            resp = ayon_api.get(f"views/{view_type}/base")
        except Exception as exc:  # noqa: BLE001
            log.exception(
                "Failed to fetch studio default view for %s", view_type
            )
            self.error.emit(f"Failed to fetch studio default view: {exc}")
            return None
        if resp.status_code != 200:
            return None
        view = View.from_payload(resp.data)
        self._id_to_view_attributes[view.id] = (view_type, view.scope)
        return view

    def list_views(self, view_type: str) -> list[View]:
        """Return views for *view_type*, fetching from server on miss.

        Returns an empty list (without emitting :attr:`error`) when the
        project name is empty — callers should wait for
        ``project_changed`` before listing.

        Args:
            view_type: View-type identifier (e.g. ``"versions"``).

        Returns:
            Sorted list of :class:`View` instances.  Empty list on
            network or parse error.
        """
        # Skip the network call when no project is set.
        if not self._project_name:
            return []

        if view_type in self._cache:
            return list(self._cache[view_type])

        try:
            resp = ayon_api.get(
                f"views/{view_type}",
                project_name=self._project_name,
            )
        except Exception as exc:  # noqa: BLE001
            log.exception("Failed to list views for %s", view_type)
            self.error.emit(f"Failed to list views: {exc}")
            return []

        payload = getattr(resp, "data", None)
        if isinstance(payload, dict):
            items = payload.get("views", payload)
        else:
            items = payload
        if not isinstance(items, list):
            items = []

        views: list[View] = []
        for item in items:
            # Skip the default view, should not be listed in the Views UI.
            if item.get("label") == DEFAULT_VIEW_LABEL:
                continue
            try:
                view = View.from_payload(item)
            except Exception:  # noqa: BLE001
                log.exception("Skipping malformed view payload: %r", item)
                continue
            # The listing carries only the metadata the selector needs to
            # draw its rows; settings arrive via :meth:`load_view` when a
            # view is actually applied or saved.
            view.loaded = False
            # Nor does it carry the view type, which every per-view
            # endpoint needs in its path.
            if not view.view_type:
                view.view_type = view_type
            views.append(view)

        views.sort(key=lambda v: (v.position, v.label.lower()))
        self._cache[view_type] = views
        # Populate id-map for delete_view lookups.
        for v in views:
            if v.id:
                self._id_to_view_attributes[v.id] = (view_type, v.scope)
        return list(views)

    def set_working_view(self, view: View) -> None:
        """Mark *view* as the sole working view for its type.

        The base implementation scans :meth:`list_views` to clear the flag
        on every other view, which costs a full listing on each save. The
        server exposes the working view directly, so at most one other
        view can be holding the flag and one request finds it - and when
        *view* already is the working view, none is needed at all.

        Args:
            view: The view to mark as working.
        """
        if not (view.working and view.id):
            current = self.get_working_view(view.view_type)
            if current is not None and current.id and current.id != view.id:
                current.working = False
                self.save_view(current)
        view.working = True
        self.save_view(view)

    def load_view(self, view: View) -> View:
        """Return *view* with its settings fetched from the server.

        :meth:`list_views` builds its rows from the listing endpoint,
        which omits ``settings``, so opening the Views menu costs one
        request instead of one per view. This fills in the rest, and is
        a no-op for a view that already carries its settings.

        Args:
            view: A view, possibly built from a listing summary.

        Returns:
            A fully populated view. The passed view is returned unchanged
            when it is already loaded, has no id, or the fetch fails -
            callers must still tolerate empty settings.
        """
        if view.loaded or not view.id:
            return view

        view_type = view.view_type or ""
        try:
            resp = ayon_api.get(
                f"views/{view_type}/{view.id}",
                project_name=self._project_name,
            )
        except Exception as exc:  # noqa: BLE001
            log.exception("Failed to fetch view %s", view.id)
            self.error.emit(f"Failed to fetch view: {exc}")
            return view

        try:
            loaded = View.from_payload(resp.data)
        except Exception:  # noqa: BLE001
            log.exception("Malformed view payload for %s", view.id)
            return view

        cached = self._cache.get(view_type)
        if cached is not None:
            for idx, item in enumerate(cached):
                if item.id == loaded.id:
                    cached[idx] = loaded
                    break
        return loaded

    def save_view(self, view: View) -> View:
        """POST a new view or PATCH an existing one.

        The new-vs-update decision is based solely on whether
        :attr:`View.id` is non-empty.  A view with a non-empty id is
        PATCHed; an empty id results in a POST.  This is correct because
        ids are server-assigned — clients never mint them — so a
        non-empty id means the view was previously persisted.

        The per-type cache is updated in-place (rather than invalidated)
        so the subsequent :attr:`views_changed` emission does not
        trigger an extra round-trip.

        Args:
            view: The view to save.

        Returns:
            The saved view, rebuilt from the server response when
            the response includes data, otherwise the input view.

        Raises:
            Exception: Re-raises any ``ayon_api`` failure after
                emitting :attr:`error`.
        """
        # A view straight off the listing has empty settings, and
        # to_payload always sends them - persisting one as-is would wipe
        # what is stored. Fetch the real settings unless the caller has
        # already supplied their own.
        if not view.loaded and view.id:
            stored = self.load_view(view)
            if stored is not view:
                view.settings = stored.settings
                view.loaded = True

        payload = view.to_payload()
        access_data = self._normalize_access_payload(payload.get("access"))
        should_share_access = self._has_positive_access(access_data)

        is_update = bool(view.id)
        if not is_update:
            payload.pop("id", None)

        try:
            # Use studio or project endpoints based on view scope
            if view.scope == Scope.STUDIO:
                # Studio-scoped views use studio endpoints
                if is_update:
                    endpoint = f"views/{view.view_type}/{view.id}"
                    resp = ayon_api.patch(endpoint, **payload)
                else:
                    endpoint = f"views/{view.view_type}"
                    resp = ayon_api.post(endpoint, **payload)
            else:
                # Project-scoped views use project-specific endpoints
                if is_update:
                    endpoint = self._endpoint(
                        f"views/{view.view_type}/{view.id}"
                    )
                    resp = ayon_api.patch(endpoint, **payload)
                else:
                    endpoint = self._endpoint(f"views/{view.view_type}")
                    resp = ayon_api.post(endpoint, **payload)
            resp.raise_for_status()
        except Exception as exc:  # noqa: BLE001
            log.exception("Failed to save view %s", view.id)
            self.error.emit(f"Failed to save view: {exc}")
            raise

        data = self._extract_data(resp)
        if isinstance(data, dict) and data:
            merged = {**payload, **data}
            try:
                saved = View.from_payload(merged)
            except Exception:  # noqa: BLE001
                log.exception(
                    "Server returned malformed view payload: %r", data
                )
                saved = view
        else:
            saved = view

        # Update the id-map and per-type cache in-place.
        if saved.id:
            self._id_to_view_attributes[saved.id] = (
                saved.view_type, saved.scope
            )
        self._upsert_cache(saved)

        # Access grants are managed by the dedicated share endpoint.
        if self.supports_sharing():
            self.patch_view_access(
                saved.id, access_data, should_share_access
            )

        self.view_saved.emit(saved.id)
        self.views_changed.emit(saved.view_type)
        return saved

    def patch_view_access(
        self,
        view_id: str,
        access_data: dict[str, Any],
        should_share_access: bool,
    ) -> None:
        """Patch per-view access data via the share endpoint.

        Args:
            view_id: Existing view identifier.
            access_data: Access mapping (e.g. ``{"__everyone__": 20}``).
            should_share_access: Whether to set the view visibility to
                public (True) or private (False).
        """
        if not view_id:
            return
        if not self._project_name:
            self.error.emit("Cannot patch view access without project name")
            return

        view_attributes = self._id_to_view_attributes.get(view_id)
        if not view_attributes:
            self.error.emit(f"Unknown view id for access patch: {view_id}")
            return
        view_type, _ = view_attributes

        payload_access = self._normalize_access_payload(access_data)
        if not payload_access:
            return

        powerpack_version = self._get_powerpack_version()
        if not powerpack_version:
            self.error.emit("Could not resolve powerpack addon version")
            return
        visibility = (
            Visibility.PUBLIC.value
            if should_share_access
            else Visibility.PRIVATE.value
        )
        try:
            con = ayon_api.get_server_api_connection()
            resp = con.raw_post(
                "addons/"
                f"{_POWERPACK_ADDON_NAME}/{powerpack_version}"
                f"/views/{view_type}/{view_id}/share",
                params={"project_name": self._project_name},
                json={
                    "visibility": visibility,
                    "access": payload_access,
                },
            )
            resp.raise_for_status()
            # Update cache with new visibility
            view_list = self._cache.get(view_type)
            if view_list is not None:
                for view in view_list:
                    if view.id == view_id:
                        view.visibility = Visibility(visibility)
                        break
        except Exception as exc:  # noqa: BLE001
            log.exception("Failed to patch view access for %s", view_id)
            self.error.emit(f"Failed to patch view access: {exc}")

    def delete_view(self, view_id: str) -> None:
        """Delete *view_id* on the server.

        The view type is resolved via the id-map (populated by
        :meth:`list_views` and :meth:`save_view`), so this works even
        when the per-type list cache has been cleared by
        :meth:`set_project`.  Unknown ids emit an :attr:`error` signal.

        Does nothing (without emitting :attr:`error`) when no project
        is set.

        Args:
            view_id: Identifier of the view to delete.
        """
        if not self._project_name:
            return

        view_attributes = self._id_to_view_attributes.get(view_id)
        if view_attributes is None:
            self.error.emit(f"Unknown view id: {view_id}")
            return

        view_type, view_scope = view_attributes
        is_studio = view_scope == Scope.STUDIO

        try:
            body = {} if is_studio else {"project_name": self._project_name}
            ayon_api.delete(f"views/{view_type}/{view_id}", **body)
        except Exception as exc:  # noqa: BLE001
            log.exception("Failed to delete view %s", view_id)
            self.error.emit(f"Failed to delete view: {exc}")
            return

        # Remove in-place from the per-type cache if populated.
        self._id_to_view_attributes.pop(view_id, None)
        view_list = self._cache.get(view_type)
        if view_list is not None:
            self._cache[view_type] = [v for v in view_list if v.id != view_id]

        self.view_deleted.emit(view_id)
        self.views_changed.emit(view_type)

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    def _endpoint(self, path: str) -> str:
        """Return *path* with ``project_name`` query string appended.

        Used for POST / PATCH which must send the body as JSON; the
        kwargs-as-body convention of :func:`ayon_api.post` clashes with
        the project_name-as-query-param requirement of the endpoint.

        Args:
            path: Endpoint path without leading slash.

        Returns:
            ``"{path}?project_name=<p>"`` when a project is set,
            otherwise *path* unchanged.
        """
        if not self._project_name:
            return path
        query = urlencode({"project_name": self._project_name})
        sep = "&" if "?" in path else "?"
        return f"{path}{sep}{query}"

    @staticmethod
    def _extract_data(resp: Any) -> Any:
        """Best-effort extraction of the JSON body from a response."""
        data = getattr(resp, "data", None)
        if data is not None:
            return data
        # Fall back to .json() for raw requests.Response-like objects.
        json_fn = getattr(resp, "json", None)
        if callable(json_fn):
            try:
                return json_fn()
            except Exception:  # noqa: BLE001
                return None
        return None

    def _upsert_cache(self, view: View) -> None:
        """Insert or replace *view* in the per-type cache in place.

        Does nothing if the type is not yet in the cache (the next
        ``list_views`` call will fetch a fresh list anyway).

        Args:
            view: The view to upsert.
        """
        view_list = self._cache.get(view.view_type)
        if view_list is None:
            return
        for i, v in enumerate(view_list):
            if v.id == view.id:
                view_list[i] = view
                break
        else:
            view_list.append(view)
        view_list.sort(key=lambda v: (v.position, v.label.lower()))

    @staticmethod
    def _normalize_access_payload(
        raw_access: dict[str, Any]
    ) -> dict[str, int]:
        """Return a normalized ``access`` payload with integer values."""
        if not isinstance(raw_access, dict):
            return {}
        normalized: dict[str, int] = {}
        for key, value in raw_access.items():
            name = str(key).strip()
            if not name:
                continue
            try:
                normalized[name] = int(value)
            except (TypeError, ValueError) as exc:
                raise ValueError(
                    f"Access value for '{name}' must be an int, got {value!r}"
                ) from exc
        return normalized

    @staticmethod
    def _has_positive_access(access_data: dict[str, int]) -> bool:
        """Return True when any access level is above zero."""
        return any(level > 0 for level in access_data.values())

    def supports_sharing(self) -> bool:
        """Whether view sharing is available on the server.

        Sharing is a powerpack feature: it requires the powerpack addon
        in the current bundle.

        Returns:
            True when powerpack is available and licensed.
        """
        return self._get_powerpack_version() is not None

    def _get_powerpack_version(self) -> str | None:
        """Return powerpack version from the current session bundle."""
        if self._powerpack_resolved:
            return self._powerpack_version

        try:
            bundle_info = get_bundle_information()
        except Exception:  # noqa: BLE001
            log.exception("Failed to query bundle information")
            return None

        self._powerpack_resolved = True
        for addon in bundle_info.addons:
            if addon.name != _POWERPACK_ADDON_NAME:
                continue
            if addon.version:
                self._powerpack_version = addon.version
                return self._powerpack_version

        return None

project_name property

Current project the manager is scoped to.

__init__(project_name, parent=None)

Initialise the manager.

Parameters:

Name Type Description Default
project_name str

Initial project the manager is scoped to. May be empty when the loader has no project yet.

required
parent QObject | None

Optional parent QObject.

None
Source code in client/ayon_core/ui/components/views/server_view_manager.py
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def __init__(
        self,
        project_name: str,
        parent: QObject | None = None,
) -> None:
    """Initialise the manager.

    Args:
        project_name: Initial project the manager is scoped to.
            May be empty when the loader has no project yet.
        parent: Optional parent QObject.
    """
    super().__init__(parent=parent)
    self._project_name = project_name
    # view_type -> sorted list of views (per-type list cache)
    self._cache: dict[str, list[View]] = {}
    # view_id -> (view_type, scope) (survives list_views; used by
    # delete_view so it doesn't require a populated per-type cache).
    self._id_to_view_attributes: dict[str, tuple[str, Scope]] = {}
    self._powerpack_version: str | None = None
    self._powerpack_resolved: bool = False

delete_view(view_id)

Delete view_id on the server.

The view type is resolved via the id-map (populated by :meth:list_views and :meth:save_view), so this works even when the per-type list cache has been cleared by :meth:set_project. Unknown ids emit an :attr:error signal.

Does nothing (without emitting :attr:error) when no project is set.

Parameters:

Name Type Description Default
view_id str

Identifier of the view to delete.

required
Source code in client/ayon_core/ui/components/views/server_view_manager.py
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
def delete_view(self, view_id: str) -> None:
    """Delete *view_id* on the server.

    The view type is resolved via the id-map (populated by
    :meth:`list_views` and :meth:`save_view`), so this works even
    when the per-type list cache has been cleared by
    :meth:`set_project`.  Unknown ids emit an :attr:`error` signal.

    Does nothing (without emitting :attr:`error`) when no project
    is set.

    Args:
        view_id: Identifier of the view to delete.
    """
    if not self._project_name:
        return

    view_attributes = self._id_to_view_attributes.get(view_id)
    if view_attributes is None:
        self.error.emit(f"Unknown view id: {view_id}")
        return

    view_type, view_scope = view_attributes
    is_studio = view_scope == Scope.STUDIO

    try:
        body = {} if is_studio else {"project_name": self._project_name}
        ayon_api.delete(f"views/{view_type}/{view_id}", **body)
    except Exception as exc:  # noqa: BLE001
        log.exception("Failed to delete view %s", view_id)
        self.error.emit(f"Failed to delete view: {exc}")
        return

    # Remove in-place from the per-type cache if populated.
    self._id_to_view_attributes.pop(view_id, None)
    view_list = self._cache.get(view_type)
    if view_list is not None:
        self._cache[view_type] = [v for v in view_list if v.id != view_id]

    self.view_deleted.emit(view_id)
    self.views_changed.emit(view_type)

get_default_project_view(view_type)

Return project default view.

Source code in client/ayon_core/ui/components/views/server_view_manager.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def get_default_project_view(self, view_type: str) -> View | None:
    """Return project default view."""
    if not self._project_name:
        return None

    try:
        resp = ayon_api.get(
            f"views/{view_type}/base",
            project_name=self._project_name,
        )
    except Exception as exc:  # noqa: BLE001
        log.exception(
            "Failed to fetch project default view for %s", view_type
        )
        self.error.emit(f"Failed to fetch project default view: {exc}")
        return None
    if resp.status_code != 200:
        return None
    view = View.from_payload(resp.data)
    self._id_to_view_attributes[view.id] = (view_type, view.scope)
    return view

get_default_studio_view(view_type)

Return studio default view.

Source code in client/ayon_core/ui/components/views/server_view_manager.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
def get_default_studio_view(self, view_type: str) -> View | None:
    """Return studio default view."""
    try:
        resp = ayon_api.get(f"views/{view_type}/base")
    except Exception as exc:  # noqa: BLE001
        log.exception(
            "Failed to fetch studio default view for %s", view_type
        )
        self.error.emit(f"Failed to fetch studio default view: {exc}")
        return None
    if resp.status_code != 200:
        return None
    view = View.from_payload(resp.data)
    self._id_to_view_attributes[view.id] = (view_type, view.scope)
    return view

get_working_view(view_type)

Return the working view for view_type using the direct endpoint.

Uses GET /api/views/{view_type}/working for faster lookup than scanning list_views.

Parameters:

Name Type Description Default
view_type str

View-type identifier.

required

Returns:

Type Description
View | None

Parsed working :class:View, or None when not found.

Source code in client/ayon_core/ui/components/views/server_view_manager.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def get_working_view(self, view_type: str) -> View | None:
    """Return the working view for *view_type* using the direct endpoint.

    Uses ``GET /api/views/{view_type}/working`` for faster lookup than
    scanning ``list_views``.

    Args:
        view_type: View-type identifier.

    Returns:
        Parsed working :class:`View`, or ``None`` when not found.
    """
    if not self._project_name:
        return None

    try:
        resp = ayon_api.get(
            f"views/{view_type}/working",
            project_name=self._project_name,
        )
    except Exception as exc:  # noqa: BLE001
        log.exception("Failed to fetch working view for %s", view_type)
        self.error.emit(f"Failed to fetch working view: {exc}")
        return None

    # No working view yet (404) - the error body is not a view, parsing
    #   it would give a view without id and view type which then fails
    #   to save.
    if resp.status_code != 200 or not resp.data:
        return None
    view = View.from_payload(resp.data)
    if not view.view_type:
        view.view_type = view_type
    return view

list_views(view_type)

Return views for view_type, fetching from server on miss.

Returns an empty list (without emitting :attr:error) when the project name is empty — callers should wait for project_changed before listing.

Parameters:

Name Type Description Default
view_type str

View-type identifier (e.g. "versions").

required

Returns:

Type Description
list[View]

Sorted list of :class:View instances. Empty list on

list[View]

network or parse error.

Source code in client/ayon_core/ui/components/views/server_view_manager.py
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
def list_views(self, view_type: str) -> list[View]:
    """Return views for *view_type*, fetching from server on miss.

    Returns an empty list (without emitting :attr:`error`) when the
    project name is empty — callers should wait for
    ``project_changed`` before listing.

    Args:
        view_type: View-type identifier (e.g. ``"versions"``).

    Returns:
        Sorted list of :class:`View` instances.  Empty list on
        network or parse error.
    """
    # Skip the network call when no project is set.
    if not self._project_name:
        return []

    if view_type in self._cache:
        return list(self._cache[view_type])

    try:
        resp = ayon_api.get(
            f"views/{view_type}",
            project_name=self._project_name,
        )
    except Exception as exc:  # noqa: BLE001
        log.exception("Failed to list views for %s", view_type)
        self.error.emit(f"Failed to list views: {exc}")
        return []

    payload = getattr(resp, "data", None)
    if isinstance(payload, dict):
        items = payload.get("views", payload)
    else:
        items = payload
    if not isinstance(items, list):
        items = []

    views: list[View] = []
    for item in items:
        # Skip the default view, should not be listed in the Views UI.
        if item.get("label") == DEFAULT_VIEW_LABEL:
            continue
        try:
            view = View.from_payload(item)
        except Exception:  # noqa: BLE001
            log.exception("Skipping malformed view payload: %r", item)
            continue
        # The listing carries only the metadata the selector needs to
        # draw its rows; settings arrive via :meth:`load_view` when a
        # view is actually applied or saved.
        view.loaded = False
        # Nor does it carry the view type, which every per-view
        # endpoint needs in its path.
        if not view.view_type:
            view.view_type = view_type
        views.append(view)

    views.sort(key=lambda v: (v.position, v.label.lower()))
    self._cache[view_type] = views
    # Populate id-map for delete_view lookups.
    for v in views:
        if v.id:
            self._id_to_view_attributes[v.id] = (view_type, v.scope)
    return list(views)

load_view(view)

Return view with its settings fetched from the server.

:meth:list_views builds its rows from the listing endpoint, which omits settings, so opening the Views menu costs one request instead of one per view. This fills in the rest, and is a no-op for a view that already carries its settings.

Parameters:

Name Type Description Default
view View

A view, possibly built from a listing summary.

required

Returns:

Type Description
View

A fully populated view. The passed view is returned unchanged

View

when it is already loaded, has no id, or the fetch fails -

View

callers must still tolerate empty settings.

Source code in client/ayon_core/ui/components/views/server_view_manager.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
def load_view(self, view: View) -> View:
    """Return *view* with its settings fetched from the server.

    :meth:`list_views` builds its rows from the listing endpoint,
    which omits ``settings``, so opening the Views menu costs one
    request instead of one per view. This fills in the rest, and is
    a no-op for a view that already carries its settings.

    Args:
        view: A view, possibly built from a listing summary.

    Returns:
        A fully populated view. The passed view is returned unchanged
        when it is already loaded, has no id, or the fetch fails -
        callers must still tolerate empty settings.
    """
    if view.loaded or not view.id:
        return view

    view_type = view.view_type or ""
    try:
        resp = ayon_api.get(
            f"views/{view_type}/{view.id}",
            project_name=self._project_name,
        )
    except Exception as exc:  # noqa: BLE001
        log.exception("Failed to fetch view %s", view.id)
        self.error.emit(f"Failed to fetch view: {exc}")
        return view

    try:
        loaded = View.from_payload(resp.data)
    except Exception:  # noqa: BLE001
        log.exception("Malformed view payload for %s", view.id)
        return view

    cached = self._cache.get(view_type)
    if cached is not None:
        for idx, item in enumerate(cached):
            if item.id == loaded.id:
                cached[idx] = loaded
                break
    return loaded

patch_view_access(view_id, access_data, should_share_access)

Patch per-view access data via the share endpoint.

Parameters:

Name Type Description Default
view_id str

Existing view identifier.

required
access_data dict[str, Any]

Access mapping (e.g. {"__everyone__": 20}).

required
should_share_access bool

Whether to set the view visibility to public (True) or private (False).

required
Source code in client/ayon_core/ui/components/views/server_view_manager.py
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
def patch_view_access(
    self,
    view_id: str,
    access_data: dict[str, Any],
    should_share_access: bool,
) -> None:
    """Patch per-view access data via the share endpoint.

    Args:
        view_id: Existing view identifier.
        access_data: Access mapping (e.g. ``{"__everyone__": 20}``).
        should_share_access: Whether to set the view visibility to
            public (True) or private (False).
    """
    if not view_id:
        return
    if not self._project_name:
        self.error.emit("Cannot patch view access without project name")
        return

    view_attributes = self._id_to_view_attributes.get(view_id)
    if not view_attributes:
        self.error.emit(f"Unknown view id for access patch: {view_id}")
        return
    view_type, _ = view_attributes

    payload_access = self._normalize_access_payload(access_data)
    if not payload_access:
        return

    powerpack_version = self._get_powerpack_version()
    if not powerpack_version:
        self.error.emit("Could not resolve powerpack addon version")
        return
    visibility = (
        Visibility.PUBLIC.value
        if should_share_access
        else Visibility.PRIVATE.value
    )
    try:
        con = ayon_api.get_server_api_connection()
        resp = con.raw_post(
            "addons/"
            f"{_POWERPACK_ADDON_NAME}/{powerpack_version}"
            f"/views/{view_type}/{view_id}/share",
            params={"project_name": self._project_name},
            json={
                "visibility": visibility,
                "access": payload_access,
            },
        )
        resp.raise_for_status()
        # Update cache with new visibility
        view_list = self._cache.get(view_type)
        if view_list is not None:
            for view in view_list:
                if view.id == view_id:
                    view.visibility = Visibility(visibility)
                    break
    except Exception as exc:  # noqa: BLE001
        log.exception("Failed to patch view access for %s", view_id)
        self.error.emit(f"Failed to patch view access: {exc}")

save_view(view)

POST a new view or PATCH an existing one.

The new-vs-update decision is based solely on whether :attr:View.id is non-empty. A view with a non-empty id is PATCHed; an empty id results in a POST. This is correct because ids are server-assigned — clients never mint them — so a non-empty id means the view was previously persisted.

The per-type cache is updated in-place (rather than invalidated) so the subsequent :attr:views_changed emission does not trigger an extra round-trip.

Parameters:

Name Type Description Default
view View

The view to save.

required

Returns:

Type Description
View

The saved view, rebuilt from the server response when

View

the response includes data, otherwise the input view.

Raises:

Type Description
Exception

Re-raises any ayon_api failure after emitting :attr:error.

Source code in client/ayon_core/ui/components/views/server_view_manager.py
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
def save_view(self, view: View) -> View:
    """POST a new view or PATCH an existing one.

    The new-vs-update decision is based solely on whether
    :attr:`View.id` is non-empty.  A view with a non-empty id is
    PATCHed; an empty id results in a POST.  This is correct because
    ids are server-assigned — clients never mint them — so a
    non-empty id means the view was previously persisted.

    The per-type cache is updated in-place (rather than invalidated)
    so the subsequent :attr:`views_changed` emission does not
    trigger an extra round-trip.

    Args:
        view: The view to save.

    Returns:
        The saved view, rebuilt from the server response when
        the response includes data, otherwise the input view.

    Raises:
        Exception: Re-raises any ``ayon_api`` failure after
            emitting :attr:`error`.
    """
    # A view straight off the listing has empty settings, and
    # to_payload always sends them - persisting one as-is would wipe
    # what is stored. Fetch the real settings unless the caller has
    # already supplied their own.
    if not view.loaded and view.id:
        stored = self.load_view(view)
        if stored is not view:
            view.settings = stored.settings
            view.loaded = True

    payload = view.to_payload()
    access_data = self._normalize_access_payload(payload.get("access"))
    should_share_access = self._has_positive_access(access_data)

    is_update = bool(view.id)
    if not is_update:
        payload.pop("id", None)

    try:
        # Use studio or project endpoints based on view scope
        if view.scope == Scope.STUDIO:
            # Studio-scoped views use studio endpoints
            if is_update:
                endpoint = f"views/{view.view_type}/{view.id}"
                resp = ayon_api.patch(endpoint, **payload)
            else:
                endpoint = f"views/{view.view_type}"
                resp = ayon_api.post(endpoint, **payload)
        else:
            # Project-scoped views use project-specific endpoints
            if is_update:
                endpoint = self._endpoint(
                    f"views/{view.view_type}/{view.id}"
                )
                resp = ayon_api.patch(endpoint, **payload)
            else:
                endpoint = self._endpoint(f"views/{view.view_type}")
                resp = ayon_api.post(endpoint, **payload)
        resp.raise_for_status()
    except Exception as exc:  # noqa: BLE001
        log.exception("Failed to save view %s", view.id)
        self.error.emit(f"Failed to save view: {exc}")
        raise

    data = self._extract_data(resp)
    if isinstance(data, dict) and data:
        merged = {**payload, **data}
        try:
            saved = View.from_payload(merged)
        except Exception:  # noqa: BLE001
            log.exception(
                "Server returned malformed view payload: %r", data
            )
            saved = view
    else:
        saved = view

    # Update the id-map and per-type cache in-place.
    if saved.id:
        self._id_to_view_attributes[saved.id] = (
            saved.view_type, saved.scope
        )
    self._upsert_cache(saved)

    # Access grants are managed by the dedicated share endpoint.
    if self.supports_sharing():
        self.patch_view_access(
            saved.id, access_data, should_share_access
        )

    self.view_saved.emit(saved.id)
    self.views_changed.emit(saved.view_type)
    return saved

set_project(project_name)

Rebind the manager to a different project.

Clears both caches and emits :attr:project_changed with the new project name so listeners can refresh.

Parameters:

Name Type Description Default
project_name str

Target project name. No-op when the same as the current project.

required
Source code in client/ayon_core/ui/components/views/server_view_manager.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
def set_project(self, project_name: str) -> None:
    """Rebind the manager to a different project.

    Clears both caches and emits :attr:`project_changed` with the
    new project name so listeners can refresh.

    Args:
        project_name: Target project name.  No-op when the same as
            the current project.
    """
    if project_name == self._project_name:
        return
    self._project_name = project_name
    self._id_to_view_attributes.clear()
    self._cache.clear()
    self.project_changed.emit(project_name)

set_working_view(view)

Mark view as the sole working view for its type.

The base implementation scans :meth:list_views to clear the flag on every other view, which costs a full listing on each save. The server exposes the working view directly, so at most one other view can be holding the flag and one request finds it - and when view already is the working view, none is needed at all.

Parameters:

Name Type Description Default
view View

The view to mark as working.

required
Source code in client/ayon_core/ui/components/views/server_view_manager.py
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def set_working_view(self, view: View) -> None:
    """Mark *view* as the sole working view for its type.

    The base implementation scans :meth:`list_views` to clear the flag
    on every other view, which costs a full listing on each save. The
    server exposes the working view directly, so at most one other
    view can be holding the flag and one request finds it - and when
    *view* already is the working view, none is needed at all.

    Args:
        view: The view to mark as working.
    """
    if not (view.working and view.id):
        current = self.get_working_view(view.view_type)
        if current is not None and current.id and current.id != view.id:
            current.working = False
            self.save_view(current)
    view.working = True
    self.save_view(view)

supports_sharing()

Whether view sharing is available on the server.

Sharing is a powerpack feature: it requires the powerpack addon in the current bundle.

Returns:

Type Description
bool

True when powerpack is available and licensed.

Source code in client/ayon_core/ui/components/views/server_view_manager.py
604
605
606
607
608
609
610
611
612
613
def supports_sharing(self) -> bool:
    """Whether view sharing is available on the server.

    Sharing is a powerpack feature: it requires the powerpack addon
    in the current bundle.

    Returns:
        True when powerpack is available and licensed.
    """
    return self._get_powerpack_version() is not None

View dataclass

A saved configuration that can be applied to a table/card view.

Attributes:

Name Type Description
id str

Server-assigned identifier. Empty string for views that have not been persisted yet.

label str

Human-readable name shown in the view selector.

view_type str

Identifier of the page/context the view belongs to (e.g. "versions", "review_sessions").

settings ViewSettings

The actual UI configuration.

owner str

User name / id of the creator.

scope Scope

"project" or "studio".

visibility Visibility

:class:Visibility enum.

loaded bool

False for a view built from a listing summary, whose settings are therefore empty rather than merely unset. Client-side only - never serialised. Managers that fetch settings lazily flip this once the full payload arrives; see :meth:ViewManager.load_view.

working bool

True for the per-user fallback view that captures ad-hoc tweaks. At most one working view per (user, view_type) should be flagged.

position int

Sort order hint within the selector.

access_level int

Required access level (0–30, step 10) for editing public views. Components store the value and gate UI via :meth:can_edit.

access dict[str, int]

Per-user/per-group access dict mapping names to integer access levels.

Source code in client/ayon_core/ui/components/views/data_models.py
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
@dataclass
class View:
    """A saved configuration that can be applied to a table/card view.

    Attributes:
        id: Server-assigned identifier.  Empty string for views that
            have not been persisted yet.
        label: Human-readable name shown in the view selector.
        view_type: Identifier of the page/context the view belongs to
            (e.g. ``"versions"``, ``"review_sessions"``).
        settings: The actual UI configuration.
        owner: User name / id of the creator.
        scope: ``"project"`` or ``"studio"``.
        visibility: :class:`Visibility` enum.
        loaded: False for a view built from a listing summary, whose
            ``settings`` are therefore empty rather than merely unset.
            Client-side only - never serialised. Managers that fetch
            settings lazily flip this once the full payload arrives; see
            :meth:`ViewManager.load_view`.
        working: True for the per-user fallback view that captures
            ad-hoc tweaks.  At most one working view per (user,
            view_type) should be flagged.
        position: Sort order hint within the selector.
        access_level: Required access level (0–30, step 10) for editing
            public views.  Components store the value and gate UI via
            :meth:`can_edit`.
        access: Per-user/per-group access dict mapping names to
            integer access levels.
    """

    id: str = ""
    label: str = ""
    view_type: str = ""
    settings: ViewSettings = field(default_factory=ViewSettings)
    owner: str = ""
    scope: Scope = Scope.PROJECT
    visibility: Visibility = Visibility.PRIVATE
    working: bool = False
    loaded: bool = True
    position: int = 0
    access_level: int = DEFAULT_ACCESS_LEVEL
    access: dict[str, int] = field(default_factory=dict)
    extra: dict[str, Any] = field(default_factory=dict)

    @classmethod
    def from_payload(cls, payload: dict[str, Any]) -> "View":
        """Build a View from a raw server payload.

        Unknown top-level keys are preserved in :attr:`extra`.

        Args:
            payload: Raw server payload dict.

        Returns:
            A new View instance.
        """
        if not isinstance(payload, dict):
            raise TypeError(
                f"View payload must be a dict, got {type(payload).__name__}"
            )

        access_level_raw = payload.get("accessLevel", DEFAULT_ACCESS_LEVEL)
        try:
            access_level = int(access_level_raw)
        except (TypeError, ValueError):
            access_level = DEFAULT_ACCESS_LEVEL

        position_raw = payload.get("position", 0)
        try:
            position = int(position_raw)
        except (TypeError, ValueError):
            position = 0

        extra = {k: v for k, v in payload.items() if k not in _KNOWN_VIEW_KEYS}

        return cls(
            id=str(payload.get("id", "")),
            label=str(payload.get("label", "")),
            view_type=str(payload.get("viewType", "")),
            settings=ViewSettings.from_payload(payload.get("settings")),
            owner=str(payload.get("owner", "")),
            scope=Scope(payload.get("scope", Scope.PROJECT.value)),
            visibility=Visibility(
                payload.get("visibility", Visibility.PRIVATE.value)
            ),
            working=bool(payload.get("working", False)),
            position=position,
            access_level=access_level,
            access=dict(payload.get("access") or {}),
            extra=extra,
        )

    def to_payload(self) -> dict[str, Any]:
        """Serialise this view to a payload dict.

        Returns:
            A dict suitable for sending to the server.
        """
        out: dict[str, Any] = {
            "id": self.id,
            "label": self.label,
            "viewType": self.view_type,
            "settings": self.settings.to_payload(),
            "owner": self.owner,
            "scope": self.scope.value,
            "visibility": self.visibility.value,
            "working": self.working,
            "position": self.position,
            "accessLevel": self.access_level,
            "access": dict(self.access),
        }
        for k, v in self.extra.items():
            if k not in _KNOWN_VIEW_KEYS:
                out[k] = v
        return out

    def can_edit(self, current_user: str, user_access_level: int = 50) -> bool:
        """Return True when *current_user* may edit this view.

        Args:
            current_user: The viewer's user name / id.
            user_access_level: The viewer's effective access level on
                the relevant scope (0–50).  Defaults to ``50`` (full
                access) so callers that don't know the user's level
                don't unexpectedly read-lock the editor.

        Returns:
            True when:

            - The view is private and the viewer owns it.
            - Or the view is public and the viewer's access level is
              greater or equal to the view's required level.
        """
        if self.visibility == Visibility.PRIVATE:
            return bool(current_user) and current_user == self.owner
        return user_access_level >= self.access_level

can_edit(current_user, user_access_level=50)

Return True when current_user may edit this view.

Parameters:

Name Type Description Default
current_user str

The viewer's user name / id.

required
user_access_level int

The viewer's effective access level on the relevant scope (0–50). Defaults to 50 (full access) so callers that don't know the user's level don't unexpectedly read-lock the editor.

50

Returns:

Type Description
bool

True when:

bool
  • The view is private and the viewer owns it.
bool
  • Or the view is public and the viewer's access level is greater or equal to the view's required level.
Source code in client/ayon_core/ui/components/views/data_models.py
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
def can_edit(self, current_user: str, user_access_level: int = 50) -> bool:
    """Return True when *current_user* may edit this view.

    Args:
        current_user: The viewer's user name / id.
        user_access_level: The viewer's effective access level on
            the relevant scope (0–50).  Defaults to ``50`` (full
            access) so callers that don't know the user's level
            don't unexpectedly read-lock the editor.

    Returns:
        True when:

        - The view is private and the viewer owns it.
        - Or the view is public and the viewer's access level is
          greater or equal to the view's required level.
    """
    if self.visibility == Visibility.PRIVATE:
        return bool(current_user) and current_user == self.owner
    return user_access_level >= self.access_level

from_payload(payload) classmethod

Build a View from a raw server payload.

Unknown top-level keys are preserved in :attr:extra.

Parameters:

Name Type Description Default
payload dict[str, Any]

Raw server payload dict.

required

Returns:

Type Description
'View'

A new View instance.

Source code in client/ayon_core/ui/components/views/data_models.py
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "View":
    """Build a View from a raw server payload.

    Unknown top-level keys are preserved in :attr:`extra`.

    Args:
        payload: Raw server payload dict.

    Returns:
        A new View instance.
    """
    if not isinstance(payload, dict):
        raise TypeError(
            f"View payload must be a dict, got {type(payload).__name__}"
        )

    access_level_raw = payload.get("accessLevel", DEFAULT_ACCESS_LEVEL)
    try:
        access_level = int(access_level_raw)
    except (TypeError, ValueError):
        access_level = DEFAULT_ACCESS_LEVEL

    position_raw = payload.get("position", 0)
    try:
        position = int(position_raw)
    except (TypeError, ValueError):
        position = 0

    extra = {k: v for k, v in payload.items() if k not in _KNOWN_VIEW_KEYS}

    return cls(
        id=str(payload.get("id", "")),
        label=str(payload.get("label", "")),
        view_type=str(payload.get("viewType", "")),
        settings=ViewSettings.from_payload(payload.get("settings")),
        owner=str(payload.get("owner", "")),
        scope=Scope(payload.get("scope", Scope.PROJECT.value)),
        visibility=Visibility(
            payload.get("visibility", Visibility.PRIVATE.value)
        ),
        working=bool(payload.get("working", False)),
        position=position,
        access_level=access_level,
        access=dict(payload.get("access") or {}),
        extra=extra,
    )

to_payload()

Serialise this view to a payload dict.

Returns:

Type Description
dict[str, Any]

A dict suitable for sending to the server.

Source code in client/ayon_core/ui/components/views/data_models.py
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
def to_payload(self) -> dict[str, Any]:
    """Serialise this view to a payload dict.

    Returns:
        A dict suitable for sending to the server.
    """
    out: dict[str, Any] = {
        "id": self.id,
        "label": self.label,
        "viewType": self.view_type,
        "settings": self.settings.to_payload(),
        "owner": self.owner,
        "scope": self.scope.value,
        "visibility": self.visibility.value,
        "working": self.working,
        "position": self.position,
        "accessLevel": self.access_level,
        "access": dict(self.access),
    }
    for k, v in self.extra.items():
        if k not in _KNOWN_VIEW_KEYS:
            out[k] = v
    return out

ViewBindings dataclass

Adapter wiring a :class:View to a concrete widget stack.

Only :attr:model is required. Every other widget is optional and skipped when None.

Attributes:

Name Type Description
model PaginatedTableModel

The shared :class:PaginatedTableModel instance.

table_view Any | None

Optional :class:AYTableView for column state.

card_view Any | None

Optional :class:AYCardView for row-height / card-size mapping.

filter_bar Any | None

Optional :class:AYTableFilter exposing :meth:get_criteria / :meth:set_criteria.

on_extra_apply Any | None

Optional callback (extra: dict) -> None invoked at the end of :meth:apply with the un-claimed :attr:ViewSettings.extra dict. Lets the consumer wire up custom settings (showProducts, gridHeight, …) without subclassing.

on_extra_capture Any | None

Optional callback () -> dict invoked during :meth:capture to merge consumer-specific keys back into :attr:ViewSettings.extra.

default_filter Callable[[], FilterDef] | None

Optional callback returning a FilterDef used when the incoming view has no filter conditions.

default_settings Callable[[], ViewSettings] | None

Optional callback returning complete defaults. When supplied, it is the preferred source for columns, sorting, filtering, grouping, row height, and consumer extras.

default_sort_by str | None

Optional column key used when the incoming view does not define a sort column.

on_error ErrorCallback | None

Optional callback (stage: str, exc: BaseException) -> None invoked for non-fatal failures during :meth:apply and :meth:capture so the host UI can surface a message. When None the failure is only logged.

Source code in client/ayon_core/ui/components/views/view_bindings.py
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
@dataclass
class ViewBindings:
    """Adapter wiring a :class:`View` to a concrete widget stack.

    Only :attr:`model` is required.  Every other widget is optional and
    skipped when ``None``.

    Attributes:
        model: The shared :class:`PaginatedTableModel` instance.
        table_view: Optional :class:`AYTableView` for column state.
        card_view: Optional :class:`AYCardView` for row-height /
            card-size mapping.
        filter_bar: Optional :class:`AYTableFilter` exposing
            :meth:`get_criteria` / :meth:`set_criteria`.
        on_extra_apply: Optional callback ``(extra: dict) -> None``
            invoked at the end of :meth:`apply` with the un-claimed
            :attr:`ViewSettings.extra` dict.  Lets the consumer wire
            up custom settings (``showProducts``, ``gridHeight``, …)
            without subclassing.
        on_extra_capture: Optional callback ``() -> dict`` invoked
            during :meth:`capture` to merge consumer-specific keys
            back into :attr:`ViewSettings.extra`.
        default_filter: Optional callback returning a ``FilterDef`` used
            when the incoming view has no filter conditions.
        default_settings: Optional callback returning complete defaults.
            When supplied, it is the preferred source for columns, sorting,
            filtering, grouping, row height, and consumer extras.
        default_sort_by: Optional column key used when the incoming view
            does not define a sort column.
        on_error: Optional callback
            ``(stage: str, exc: BaseException) -> None`` invoked for
            non-fatal failures during :meth:`apply` and :meth:`capture`
            so the host UI can surface a message.  When ``None`` the
            failure is only logged.
    """

    model: PaginatedTableModel
    table_view: Any | None = None
    card_view: Any | None = None
    filter_bar: Any | None = None
    on_extra_apply: Any | None = None
    on_extra_capture: Any | None = None
    default_columns: Callable[[], list[ColumnState]] | None = None
    default_filter: Callable[[], FilterDef] | None = None
    default_settings: Callable[[], ViewSettings] | None = None
    default_sort_by: str | None = None
    default_sort_desc: bool = False
    on_error: ErrorCallback | None = None
    # Keys not consumed by built-in handlers; preserved verbatim for
    # roundtrip even when the consumer does not supply a callback.
    _untouched_extra: dict[str, Any] = field(default_factory=dict)
    # Last applied filter operator — preserved across capture so an
    # incoming ``operator="or"`` is not silently downgraded to ``"and"``
    # when AYTableFilter does not yet expose operator semantics.
    _last_filter_operator: str = "and"
    # Last applied grouping — round-tripped verbatim because no
    # grouping widget exists yet.
    _last_grouping: GroupingDef = field(default_factory=GroupingDef)

    # ------------------------------------------------------------------
    # Error reporting
    # ------------------------------------------------------------------

    def _report_error(self, stage: str, exc: BaseException) -> None:
        """Log the failure and forward it to :attr:`on_error` when set.

        The log line always uses ``logging.ERROR`` so problems are
        visible without enabling debug output.  The optional
        :attr:`on_error` callback receives the same ``(stage, exc)``
        pair so the host UI can surface a message to the user.

        Args:
            stage: Short identifier of the failing operation
                (e.g. ``"column_state"``).
            exc: The caught exception.
        """
        log.exception("ViewBindings %s failed: %s", stage, exc)
        if self.on_error is None:
            return
        try:
            self.on_error(stage, exc)
        except Exception:
            log.exception("on_error callback raised while handling %s", stage)

    # ------------------------------------------------------------------
    # Apply
    # ------------------------------------------------------------------

    def apply(self, settings: ViewSettings) -> None:
        """Apply *settings* to every wired widget.

        Args:
            settings: The :class:`ViewSettings` to apply.

        Raises:
            TypeError: If *settings* is not a :class:`ViewSettings`.
        """
        if not isinstance(settings, ViewSettings):
            raise TypeError(
                f"apply expected ViewSettings, got {type(settings).__name__}"
            )

        defaults = (
            self.default_settings()
            if self.default_settings is not None
            else None
        )

        # 1. Columns + sort live on the model.  When the consumer also
        #    wired a table view, widths and visibility are the header's
        #    responsibility — strip them from the model payload so the
        #    model only owns ordering and sort, avoiding cross-view
        #    contamination of the shared TableColumn catalog.
        if not settings.columns and defaults is not None:
            settings = copy.copy(settings)
            settings.columns = copy.deepcopy(defaults.columns)
        elif not settings.columns and self.default_columns is not None:
            settings = copy.copy(settings)
            settings.columns = self.default_columns()

        if settings.sort_by is None and defaults is not None:
            settings = copy.copy(settings)
            settings.sort_by = defaults.sort_by
            settings.sort_desc = defaults.sort_desc
        elif settings.sort_by is None and self.default_sort_by is not None:
            settings = copy.copy(settings)
            settings.sort_by = self.default_sort_by
            settings.sort_desc = self.default_sort_desc

        model_settings = self._settings_for_model(settings)
        self.model.apply_settings(model_settings)

        # 1b. Column hidden state / pinning / width live on the header.
        if self.table_view is not None and settings.columns:
            try:
                self.table_view.set_column_state(settings.columns)
            except Exception as exc:
                self._report_error("column_state_apply", exc)

        # 2. Filter — opaque condition dicts in the View are converted
        #    to FilterCriterion via FilterCriterion.from_def.  The
        #    operator is stashed for capture() because AYTableFilter
        #    does not yet expose AND/OR semantics.
        self._last_filter_operator = settings.filter.operator or "and"
        if self.filter_bar is not None:
            filter_def = settings.filter
            if not filter_def.conditions and defaults is not None:
                filter_def = defaults.filter
            elif (
                not filter_def.conditions
                and self.default_filter is not None
            ):
                filter_def = self.default_filter()
            self._apply_filter(filter_def)

        # 3. Grouping — placeholder for future grouping widget; we keep
        #    the data on the binding so capture() can re-emit it.
        grouping = settings.grouping
        if grouping.is_empty() and defaults is not None:
            grouping = defaults.grouping
        self._last_grouping = copy.deepcopy(grouping)

        # 4. Row height — applied to the table view (real per-row
        #    override) and best-effort on the card view.
        row_height = settings.row_height
        if row_height <= 0 and defaults is not None:
            row_height = defaults.row_height
        self._apply_row_height(row_height)

        # 5. Extras — let the consumer handle the un-claimed slice.
        self._untouched_extra = dict(settings.extra)
        applied_extra = (
            dict(defaults.extra)
            if defaults is not None
            else {}
        )
        applied_extra.update(settings.extra)
        if self.on_extra_apply is not None and applied_extra:
            try:
                self.on_extra_apply(applied_extra)
            except Exception as exc:
                self._report_error("on_extra_apply", exc)

    # ------------------------------------------------------------------
    # Capture
    # ------------------------------------------------------------------

    def capture(self) -> ViewSettings:
        """Build a fresh :class:`ViewSettings` from the live widget state.

        Returns:
            A new :class:`ViewSettings` instance.
        """
        settings = self.model.capture_settings()

        # Column visibility/pinning come from the header, overriding the
        # plain key/width snapshot returned by the model.
        if self.table_view is not None:
            try:
                header_states = self.table_view.get_column_state()
            except Exception as exc:
                self._report_error("column_state_capture", exc)
                header_states = []
            if header_states:
                settings.columns = list(header_states)

        # Filter conditions.
        if self.filter_bar is not None:
            settings.filter = self.capture_filter()

        # Grouping (round-tripped verbatim; no widget yet).
        settings.grouping = copy.deepcopy(self._last_grouping)

        # Row height.  Never let a 0 ("unset") reach the payload - a
        # stored 0 makes the next apply() silently fall back to the
        # defaults, so a row height the user did pick would be lost on
        # the next round trip.
        row_height = self._capture_row_height(settings.row_height)
        if row_height <= 0:
            row_height = self._default_row_height()
        settings.row_height = row_height

        # Extras.
        extras: dict[str, Any] = dict(self._untouched_extra)
        if self.on_extra_capture is not None:
            try:
                emitted = self.on_extra_capture() or {}
            except Exception as exc:
                self._report_error("on_extra_capture", exc)
                emitted = {}
            if isinstance(emitted, dict):
                extras.update(emitted)
        settings.extra = extras

        return settings

    # ------------------------------------------------------------------
    # Settings helpers
    # ------------------------------------------------------------------

    def _settings_for_model(self, settings: ViewSettings) -> ViewSettings:
        """Return a copy of *settings* with widths stripped for the model.

        The model and the table-view header both consume
        ``settings.columns``.  Letting both write widths mutates the
        shared :class:`TableColumn` catalog and can leak widths across
        views that share the same model.  This helper hands the model a
        copy where every column ``width`` is ``None`` so only the
        header (via :meth:`AYTableView.set_column_state`) ever writes
        widths.

        Args:
            settings: The original :class:`ViewSettings`.

        Returns:
            A shallow-cloned :class:`ViewSettings` whose
            :class:`ColumnState` instances have ``width=None`` when a
            table view is wired; the original object otherwise.
        """
        if self.table_view is None:
            return settings
        stripped_cols = [
            ColumnState(
                name=c.name,
                visible=c.visible,
                pinned=c.pinned,
                width=None,
            )
            for c in settings.columns
        ]
        return ViewSettings(
            columns=stripped_cols,
            sort_by=settings.sort_by,
            sort_desc=settings.sort_desc,
            row_height=settings.row_height,
            grouping=settings.grouping,
            filter=settings.filter,
            extra=settings.extra,
        )

    # ------------------------------------------------------------------
    # Filter helpers
    # ------------------------------------------------------------------

    def _apply_filter(self, filter_def: FilterDef) -> None:
        """Convert *filter_def* to FilterCriteria and push to the bar.

        Args:
            filter_def: The :class:`FilterDef` to apply.
        """
        # Lazy import keeps the views package self-contained and avoids
        # a circular import between views and table_filter.
        from ..table_filter import FilterCriterion

        criteria = [
            FilterCriterion.from_def(c)
            for c in filter_def.conditions
            if isinstance(c, dict)
        ]
        try:
            self.filter_bar.set_active_criteria(criteria)
        except Exception as exc:
            self._report_error("filter_apply", exc)

    def capture_filter(self) -> FilterDef:
        """Capture filter criteria from the live filter bar.

        The operator is preserved from the last :meth:`apply` call so a
        view that arrived with ``operator="or"`` roundtrips losslessly,
        even though :class:`AYTableFilter` only implements AND.

        Returns:
            A new :class:`FilterDef`.
        """
        try:
            criteria = self.filter_bar.get_criteria()
        except Exception as exc:
            self._report_error("filter_capture", exc)
            return FilterDef(operator=self._last_filter_operator)
        return FilterDef(
            conditions=[c.to_def() for c in criteria],
            operator=self._last_filter_operator,
        )

    # ------------------------------------------------------------------
    # Row-height helpers
    # ------------------------------------------------------------------

    def _apply_row_height(self, row_height: int) -> None:
        """Apply *row_height* to whichever views are wired.

        The table view receives the real pixel value via
        :meth:`AYTableView.set_row_height`; the card view is mapped
        only when it exposes a real ``set_card_height`` setter.
        Mapping table-row-height to card *width* is never done — the
        units do not match and a 32px-wide card is not useful.

        Args:
            row_height: Row height in pixels (``<= 0`` = ignore).
        """
        if row_height <= 0:
            return

        if self.table_view is not None:
            setter = getattr(self.table_view, "set_row_height", None)
            if callable(setter):
                try:
                    setter(row_height)
                except Exception as exc:
                    self._report_error("row_height_table", exc)

        if self.card_view is not None:
            setter = getattr(self.card_view, "set_card_height", None)
            if callable(setter):
                try:
                    setter(row_height)
                except Exception as exc:
                    self._report_error("row_height_card", exc)

    def _default_row_height(self) -> int:
        """Return the consumer's default row height, or ``0``.

        Returns:
            ``default_settings().row_height`` when a defaults callback
            is wired and returns a positive height; ``0`` otherwise.
        """
        if self.default_settings is None:
            return 0
        try:
            return int(self.default_settings().row_height)
        except Exception as exc:  # noqa: BLE001
            self._report_error("default_row_height", exc)
            return 0

    def _capture_row_height(self, fallback: int) -> int:
        """Best-effort read of the current row height.

        Args:
            fallback: Value to return when no widget can answer.

        Returns:
            Row height in pixels.
        """
        if self.table_view is not None:
            getter = getattr(self.table_view, "row_height", None)
            override = (
                getter()
                if callable(getter)
                else getattr(self.table_view, "_row_height_override", None)
            )
            if isinstance(override, int) and override > 0:
                return int(override)
            # Use the visualRect of the first index when available.
            try:
                model = self.table_view.model()
                if model is not None and model.rowCount() > 0:
                    idx = model.index(0, 0)
                    rect = self.table_view.visualRect(idx)
                    if rect.height() > 0:
                        return int(rect.height())
            except Exception:
                log.debug("Could not measure table row height", exc_info=True)
        return fallback

apply(settings)

Apply settings to every wired widget.

Parameters:

Name Type Description Default
settings ViewSettings

The :class:ViewSettings to apply.

required

Raises:

Type Description
TypeError

If settings is not a :class:ViewSettings.

Source code in client/ayon_core/ui/components/views/view_bindings.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def apply(self, settings: ViewSettings) -> None:
    """Apply *settings* to every wired widget.

    Args:
        settings: The :class:`ViewSettings` to apply.

    Raises:
        TypeError: If *settings* is not a :class:`ViewSettings`.
    """
    if not isinstance(settings, ViewSettings):
        raise TypeError(
            f"apply expected ViewSettings, got {type(settings).__name__}"
        )

    defaults = (
        self.default_settings()
        if self.default_settings is not None
        else None
    )

    # 1. Columns + sort live on the model.  When the consumer also
    #    wired a table view, widths and visibility are the header's
    #    responsibility — strip them from the model payload so the
    #    model only owns ordering and sort, avoiding cross-view
    #    contamination of the shared TableColumn catalog.
    if not settings.columns and defaults is not None:
        settings = copy.copy(settings)
        settings.columns = copy.deepcopy(defaults.columns)
    elif not settings.columns and self.default_columns is not None:
        settings = copy.copy(settings)
        settings.columns = self.default_columns()

    if settings.sort_by is None and defaults is not None:
        settings = copy.copy(settings)
        settings.sort_by = defaults.sort_by
        settings.sort_desc = defaults.sort_desc
    elif settings.sort_by is None and self.default_sort_by is not None:
        settings = copy.copy(settings)
        settings.sort_by = self.default_sort_by
        settings.sort_desc = self.default_sort_desc

    model_settings = self._settings_for_model(settings)
    self.model.apply_settings(model_settings)

    # 1b. Column hidden state / pinning / width live on the header.
    if self.table_view is not None and settings.columns:
        try:
            self.table_view.set_column_state(settings.columns)
        except Exception as exc:
            self._report_error("column_state_apply", exc)

    # 2. Filter — opaque condition dicts in the View are converted
    #    to FilterCriterion via FilterCriterion.from_def.  The
    #    operator is stashed for capture() because AYTableFilter
    #    does not yet expose AND/OR semantics.
    self._last_filter_operator = settings.filter.operator or "and"
    if self.filter_bar is not None:
        filter_def = settings.filter
        if not filter_def.conditions and defaults is not None:
            filter_def = defaults.filter
        elif (
            not filter_def.conditions
            and self.default_filter is not None
        ):
            filter_def = self.default_filter()
        self._apply_filter(filter_def)

    # 3. Grouping — placeholder for future grouping widget; we keep
    #    the data on the binding so capture() can re-emit it.
    grouping = settings.grouping
    if grouping.is_empty() and defaults is not None:
        grouping = defaults.grouping
    self._last_grouping = copy.deepcopy(grouping)

    # 4. Row height — applied to the table view (real per-row
    #    override) and best-effort on the card view.
    row_height = settings.row_height
    if row_height <= 0 and defaults is not None:
        row_height = defaults.row_height
    self._apply_row_height(row_height)

    # 5. Extras — let the consumer handle the un-claimed slice.
    self._untouched_extra = dict(settings.extra)
    applied_extra = (
        dict(defaults.extra)
        if defaults is not None
        else {}
    )
    applied_extra.update(settings.extra)
    if self.on_extra_apply is not None and applied_extra:
        try:
            self.on_extra_apply(applied_extra)
        except Exception as exc:
            self._report_error("on_extra_apply", exc)

capture()

Build a fresh :class:ViewSettings from the live widget state.

Returns:

Type Description
ViewSettings

A new :class:ViewSettings instance.

Source code in client/ayon_core/ui/components/views/view_bindings.py
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
def capture(self) -> ViewSettings:
    """Build a fresh :class:`ViewSettings` from the live widget state.

    Returns:
        A new :class:`ViewSettings` instance.
    """
    settings = self.model.capture_settings()

    # Column visibility/pinning come from the header, overriding the
    # plain key/width snapshot returned by the model.
    if self.table_view is not None:
        try:
            header_states = self.table_view.get_column_state()
        except Exception as exc:
            self._report_error("column_state_capture", exc)
            header_states = []
        if header_states:
            settings.columns = list(header_states)

    # Filter conditions.
    if self.filter_bar is not None:
        settings.filter = self.capture_filter()

    # Grouping (round-tripped verbatim; no widget yet).
    settings.grouping = copy.deepcopy(self._last_grouping)

    # Row height.  Never let a 0 ("unset") reach the payload - a
    # stored 0 makes the next apply() silently fall back to the
    # defaults, so a row height the user did pick would be lost on
    # the next round trip.
    row_height = self._capture_row_height(settings.row_height)
    if row_height <= 0:
        row_height = self._default_row_height()
    settings.row_height = row_height

    # Extras.
    extras: dict[str, Any] = dict(self._untouched_extra)
    if self.on_extra_capture is not None:
        try:
            emitted = self.on_extra_capture() or {}
        except Exception as exc:
            self._report_error("on_extra_capture", exc)
            emitted = {}
        if isinstance(emitted, dict):
            extras.update(emitted)
    settings.extra = extras

    return settings

capture_filter()

Capture filter criteria from the live filter bar.

The operator is preserved from the last :meth:apply call so a view that arrived with operator="or" roundtrips losslessly, even though :class:AYTableFilter only implements AND.

Returns:

Type Description
FilterDef

A new :class:FilterDef.

Source code in client/ayon_core/ui/components/views/view_bindings.py
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
def capture_filter(self) -> FilterDef:
    """Capture filter criteria from the live filter bar.

    The operator is preserved from the last :meth:`apply` call so a
    view that arrived with ``operator="or"`` roundtrips losslessly,
    even though :class:`AYTableFilter` only implements AND.

    Returns:
        A new :class:`FilterDef`.
    """
    try:
        criteria = self.filter_bar.get_criteria()
    except Exception as exc:
        self._report_error("filter_capture", exc)
        return FilterDef(operator=self._last_filter_operator)
    return FilterDef(
        conditions=[c.to_def() for c in criteria],
        operator=self._last_filter_operator,
    )

ViewManager

Bases: QObject

Abstract storage backend for :class:View instances.

Signals

views_changed(str): Emitted with view_type whenever the list of views for that type may have changed. view_saved(str): Emitted with the view id after a successful save_view. view_deleted(str): Emitted with the view id after a successful delete_view. project_changed(str): Emitted with the new project name when the manager is rebound to a different project. error(str): Emitted with an error message when an operation fails (subclasses may emit this instead of raising).

Source code in client/ayon_core/ui/components/views/view_manager.py
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
class ViewManager(QObject):
    """Abstract storage backend for :class:`View` instances.

    Signals:
        views_changed(str): Emitted with ``view_type`` whenever the
            list of views for that type may have changed.
        view_saved(str): Emitted with the view id after a successful
            ``save_view``.
        view_deleted(str): Emitted with the view id after a successful
            ``delete_view``.
        project_changed(str): Emitted with the new project name when the
            manager is rebound to a different project.
        error(str): Emitted with an error message when an operation
            fails (subclasses may emit this instead of raising).
    """

    views_changed = Signal(str)
    view_saved = Signal(str)
    view_deleted = Signal(str)
    project_changed = Signal(str)
    error = Signal(str)

    def __init__(self, parent: QObject | None = None) -> None:
        """Initialise the manager.

        Args:
            parent: Optional parent QObject.
        """
        super().__init__(parent)

    # -- abstract API -------------------------------------------------------

    @abstractmethod
    def list_views(self, view_type: str) -> list[View]:
        """Return the list of views for the given view type.

        Args:
            view_type: The view-type identifier (e.g. ``"versions"``).

        Returns:
            List of :class:`View` instances, sorted by
            :attr:`View.position` then :attr:`View.label`.
        """

    @abstractmethod
    def save_view(self, view: View) -> View:
        """Persist a new or updated view.

        Implementations must assign a non-empty :attr:`View.id` when
        creating a new view and return the (possibly modified) view.

        Args:
            view: The view to save.

        Returns:
            The saved view, with its assigned id.
        """

    @abstractmethod
    def delete_view(self, view_id: str) -> None:
        """Delete the view with the given id.

        Args:
            view_id: Identifier of the view to delete.
        """

    # -- optional helpers ---------------------------------------------------

    def supports_sharing(self) -> bool:
        """Whether views can be shared with other users.

        Sharing controls in the UI (the "Shared views" section, the
        people-with-access editor) are shown only when this returns
        True.  The default implementation has no restrictions.

        Returns:
            True when views can be shared.
        """
        return True

    def load_view(self, view: View) -> View:
        """Return *view* with its :attr:`View.settings` populated.

        Managers that list views cheaply - fetching only the metadata the
        dropdown needs - override this to fetch the full payload the first
        time a view is actually used. The default implementation assumes
        :meth:`list_views` already returned complete views.

        Args:
            view: The view to complete.

        Returns:
            A view whose settings are safe to apply or persist.
        """
        return view

    def get_working_view(self, view_type: str) -> View | None:
        """Return the working view for *view_type*, if any.

        Args:
            view_type: The view-type identifier.

        Returns:
            The view flagged with ``working=True`` for *view_type*, or
            ``None`` when no working view exists.
        """
        for view in self.list_views(view_type):
            if view.working:
                return view
        return None

    def get_default_studio_view(self, view_type: str) -> View | None:
        """Return the studio-scope default view for *view_type*, if any.

        Default views are saved with :data:`DEFAULT_VIEW_LABEL` and the
        matching :class:`Scope` (see
        ``DefaultViewControl._set_default_for_scope``). Subclasses backed
        by a dedicated endpoint (e.g. :class:`ServerViewManager`) should
        override this rather than rely on filtering :meth:`list_views`.

        Args:
            view_type: The view-type identifier.

        Returns:
            The studio default view for *view_type*, or ``None``.
        """
        for view in self.list_views(view_type):
            if view.scope == Scope.STUDIO and view.label == DEFAULT_VIEW_LABEL:
                return view
        return None

    def get_default_project_view(self, view_type: str) -> View | None:
        """Return the project-scope default view for *view_type*, if any.

        See :meth:`get_default_studio_view` for how default views are
        identified.

        Args:
            view_type: The view-type identifier.

        Returns:
            The project default view for *view_type*, or ``None``.
        """
        for view in self.list_views(view_type):
            if (
                view.scope == Scope.PROJECT
                and view.label == DEFAULT_VIEW_LABEL
            ):
                return view
        return None

    def set_working_view(self, view: View) -> None:
        """Mark *view* as the (sole) working view for its view type.

        The default implementation clears the ``working`` flag on every
        other view of the same type and saves them all.  Subclasses can
        override this with a bulk-update endpoint for efficiency.

        Args:
            view: The view to mark as working.
        """
        for other in self.list_views(view.view_type):
            if other.id == view.id:
                continue
            if other.working:
                other.working = False
                self.save_view(other)
        view.working = True
        self.save_view(view)

__init__(parent=None)

Initialise the manager.

Parameters:

Name Type Description Default
parent QObject | None

Optional parent QObject.

None
Source code in client/ayon_core/ui/components/views/view_manager.py
52
53
54
55
56
57
58
def __init__(self, parent: QObject | None = None) -> None:
    """Initialise the manager.

    Args:
        parent: Optional parent QObject.
    """
    super().__init__(parent)

delete_view(view_id) abstractmethod

Delete the view with the given id.

Parameters:

Name Type Description Default
view_id str

Identifier of the view to delete.

required
Source code in client/ayon_core/ui/components/views/view_manager.py
88
89
90
91
92
93
94
@abstractmethod
def delete_view(self, view_id: str) -> None:
    """Delete the view with the given id.

    Args:
        view_id: Identifier of the view to delete.
    """

get_default_project_view(view_type)

Return the project-scope default view for view_type, if any.

See :meth:get_default_studio_view for how default views are identified.

Parameters:

Name Type Description Default
view_type str

The view-type identifier.

required

Returns:

Type Description
View | None

The project default view for view_type, or None.

Source code in client/ayon_core/ui/components/views/view_manager.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def get_default_project_view(self, view_type: str) -> View | None:
    """Return the project-scope default view for *view_type*, if any.

    See :meth:`get_default_studio_view` for how default views are
    identified.

    Args:
        view_type: The view-type identifier.

    Returns:
        The project default view for *view_type*, or ``None``.
    """
    for view in self.list_views(view_type):
        if (
            view.scope == Scope.PROJECT
            and view.label == DEFAULT_VIEW_LABEL
        ):
            return view
    return None

get_default_studio_view(view_type)

Return the studio-scope default view for view_type, if any.

Default views are saved with :data:DEFAULT_VIEW_LABEL and the matching :class:Scope (see DefaultViewControl._set_default_for_scope). Subclasses backed by a dedicated endpoint (e.g. :class:ServerViewManager) should override this rather than rely on filtering :meth:list_views.

Parameters:

Name Type Description Default
view_type str

The view-type identifier.

required

Returns:

Type Description
View | None

The studio default view for view_type, or None.

Source code in client/ayon_core/ui/components/views/view_manager.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def get_default_studio_view(self, view_type: str) -> View | None:
    """Return the studio-scope default view for *view_type*, if any.

    Default views are saved with :data:`DEFAULT_VIEW_LABEL` and the
    matching :class:`Scope` (see
    ``DefaultViewControl._set_default_for_scope``). Subclasses backed
    by a dedicated endpoint (e.g. :class:`ServerViewManager`) should
    override this rather than rely on filtering :meth:`list_views`.

    Args:
        view_type: The view-type identifier.

    Returns:
        The studio default view for *view_type*, or ``None``.
    """
    for view in self.list_views(view_type):
        if view.scope == Scope.STUDIO and view.label == DEFAULT_VIEW_LABEL:
            return view
    return None

get_working_view(view_type)

Return the working view for view_type, if any.

Parameters:

Name Type Description Default
view_type str

The view-type identifier.

required

Returns:

Type Description
View | None

The view flagged with working=True for view_type, or

View | None

None when no working view exists.

Source code in client/ayon_core/ui/components/views/view_manager.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def get_working_view(self, view_type: str) -> View | None:
    """Return the working view for *view_type*, if any.

    Args:
        view_type: The view-type identifier.

    Returns:
        The view flagged with ``working=True`` for *view_type*, or
        ``None`` when no working view exists.
    """
    for view in self.list_views(view_type):
        if view.working:
            return view
    return None

list_views(view_type) abstractmethod

Return the list of views for the given view type.

Parameters:

Name Type Description Default
view_type str

The view-type identifier (e.g. "versions").

required

Returns:

Type Description
list[View]

List of :class:View instances, sorted by

list[View]

attr:View.position then :attr:View.label.

Source code in client/ayon_core/ui/components/views/view_manager.py
62
63
64
65
66
67
68
69
70
71
72
@abstractmethod
def list_views(self, view_type: str) -> list[View]:
    """Return the list of views for the given view type.

    Args:
        view_type: The view-type identifier (e.g. ``"versions"``).

    Returns:
        List of :class:`View` instances, sorted by
        :attr:`View.position` then :attr:`View.label`.
    """

load_view(view)

Return view with its :attr:View.settings populated.

Managers that list views cheaply - fetching only the metadata the dropdown needs - override this to fetch the full payload the first time a view is actually used. The default implementation assumes :meth:list_views already returned complete views.

Parameters:

Name Type Description Default
view View

The view to complete.

required

Returns:

Type Description
View

A view whose settings are safe to apply or persist.

Source code in client/ayon_core/ui/components/views/view_manager.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def load_view(self, view: View) -> View:
    """Return *view* with its :attr:`View.settings` populated.

    Managers that list views cheaply - fetching only the metadata the
    dropdown needs - override this to fetch the full payload the first
    time a view is actually used. The default implementation assumes
    :meth:`list_views` already returned complete views.

    Args:
        view: The view to complete.

    Returns:
        A view whose settings are safe to apply or persist.
    """
    return view

save_view(view) abstractmethod

Persist a new or updated view.

Implementations must assign a non-empty :attr:View.id when creating a new view and return the (possibly modified) view.

Parameters:

Name Type Description Default
view View

The view to save.

required

Returns:

Type Description
View

The saved view, with its assigned id.

Source code in client/ayon_core/ui/components/views/view_manager.py
74
75
76
77
78
79
80
81
82
83
84
85
86
@abstractmethod
def save_view(self, view: View) -> View:
    """Persist a new or updated view.

    Implementations must assign a non-empty :attr:`View.id` when
    creating a new view and return the (possibly modified) view.

    Args:
        view: The view to save.

    Returns:
        The saved view, with its assigned id.
    """

set_working_view(view)

Mark view as the (sole) working view for its view type.

The default implementation clears the working flag on every other view of the same type and saves them all. Subclasses can override this with a bulk-update endpoint for efficiency.

Parameters:

Name Type Description Default
view View

The view to mark as working.

required
Source code in client/ayon_core/ui/components/views/view_manager.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def set_working_view(self, view: View) -> None:
    """Mark *view* as the (sole) working view for its view type.

    The default implementation clears the ``working`` flag on every
    other view of the same type and saves them all.  Subclasses can
    override this with a bulk-update endpoint for efficiency.

    Args:
        view: The view to mark as working.
    """
    for other in self.list_views(view.view_type):
        if other.id == view.id:
            continue
        if other.working:
            other.working = False
            self.save_view(other)
    view.working = True
    self.save_view(view)

supports_sharing()

Whether views can be shared with other users.

Sharing controls in the UI (the "Shared views" section, the people-with-access editor) are shown only when this returns True. The default implementation has no restrictions.

Returns:

Type Description
bool

True when views can be shared.

Source code in client/ayon_core/ui/components/views/view_manager.py
 98
 99
100
101
102
103
104
105
106
107
108
def supports_sharing(self) -> bool:
    """Whether views can be shared with other users.

    Sharing controls in the UI (the "Shared views" section, the
    people-with-access editor) are shown only when this returns
    True.  The default implementation has no restrictions.

    Returns:
        True when views can be shared.
    """
    return True

ViewSettings dataclass

The actual user-visible configuration of a View.

All "generic" knobs (columns, sort, row height, filter, grouping) are first-class fields. Anything else (showProducts, gridHeight, featuredVersionOrder, slicerType, …) is stashed verbatim in :attr:extra so the payload roundtrips losslessly across app versions.

Attributes:

Name Type Description
columns list[ColumnState]

Ordered list of column states.

sort_by str | None

Column key to sort by, or None for no sort.

sort_desc bool

Whether the sort is descending.

row_height int

Table row height in pixels. Consumers without a table may use this for another height-based presentation setting.

grouping GroupingDef

Grouping configuration.

filter FilterDef

Filter configuration.

extra dict[str, Any]

All other settings keys, preserved untouched.

Source code in client/ayon_core/ui/components/views/data_models.py
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
@dataclass
class ViewSettings:
    """The actual user-visible configuration of a View.

    All "generic" knobs (columns, sort, row height, filter, grouping)
    are first-class fields.  Anything else (``showProducts``,
    ``gridHeight``, ``featuredVersionOrder``, ``slicerType``, …) is
    stashed verbatim in :attr:`extra` so the payload roundtrips
    losslessly across app versions.

    Attributes:
        columns: Ordered list of column states.
        sort_by: Column key to sort by, or ``None`` for no sort.
        sort_desc: Whether the sort is descending.
        row_height: Table row height in pixels. Consumers without a table
            may use this for another height-based presentation setting.
        grouping: Grouping configuration.
        filter: Filter configuration.
        extra: All other settings keys, preserved untouched.
    """

    columns: list[ColumnState] = field(default_factory=list)
    sort_by: str | None = DEFAULT_SORT_BY
    sort_desc: bool = DEFAULT_SORT_DESC
    row_height: int = DEFAULT_ROW_HEIGHT
    grouping: GroupingDef = field(default_factory=GroupingDef)
    filter: FilterDef = field(default_factory=FilterDef)
    extra: dict[str, Any] = field(default_factory=dict)

    @classmethod
    def from_payload(cls, payload: dict[str, Any] | None) -> "ViewSettings":
        """Build ViewSettings from the payload's ``settings`` sub-dict.

        Unknown keys are preserved verbatim in :attr:`extra`.

        Args:
            payload: Settings sub-dict, or ``None`` for defaults.

        Returns:
            A new ViewSettings instance.
        """
        if not payload:
            return cls()

        raw_cols = payload.get("columns") or []
        columns = [
            ColumnState.from_payload(c)
            for c in raw_cols
            if isinstance(c, dict)
        ]

        sort_by = payload.get("sortBy")
        if sort_by is not None:
            sort_by = str(sort_by) or None

        row_height_raw = payload.get("rowHeight", DEFAULT_ROW_HEIGHT)
        try:
            row_height = int(row_height_raw)
        except (TypeError, ValueError):
            row_height = DEFAULT_ROW_HEIGHT

        grouping = GroupingDef(
            group_by=(
                str(payload["groupBy"]) if payload.get("groupBy") else None
            ),
            group_sort_desc=bool(payload.get("groupSortByDesc", False)),
            show_empty_groups=bool(payload.get("showEmptyGroups", False)),
        )

        filter_def = FilterDef.from_payload(payload.get("filter"))

        extra = {
            k: v for k, v in payload.items() if k not in _KNOWN_SETTINGS_KEYS
        }

        return cls(
            columns=columns,
            sort_by=sort_by,
            sort_desc=bool(payload.get("sortDesc", False)),
            row_height=row_height,
            grouping=grouping,
            filter=filter_def,
            extra=extra,
        )

    def to_payload(self) -> dict[str, Any]:
        """Serialise these settings to a payload dict.

        Unknown keys from :attr:`extra` are merged back in.  Known keys
        in ``extra`` are ignored (first-class fields win) so accidental
        duplication can't corrupt a roundtrip.

        Returns:
            A dict suitable for embedding under ``"settings"`` in a
            View payload.
        """
        out: dict[str, Any] = {
            "columns": [c.to_payload() for c in self.columns],
            "sortDesc": self.sort_desc,
            "rowHeight": self.row_height,
            "groupSortByDesc": self.grouping.group_sort_desc,
            "showEmptyGroups": self.grouping.show_empty_groups,
            "filter": self.filter.to_payload(),
        }
        if self.sort_by is not None:
            out["sortBy"] = self.sort_by
        if self.grouping.group_by is not None:
            out["groupBy"] = self.grouping.group_by

        # Merge extras last but never let them override first-class keys.
        for k, v in self.extra.items():
            if k not in _KNOWN_SETTINGS_KEYS:
                out[k] = v
        return out

from_payload(payload) classmethod

Build ViewSettings from the payload's settings sub-dict.

Unknown keys are preserved verbatim in :attr:extra.

Parameters:

Name Type Description Default
payload dict[str, Any] | None

Settings sub-dict, or None for defaults.

required

Returns:

Type Description
'ViewSettings'

A new ViewSettings instance.

Source code in client/ayon_core/ui/components/views/data_models.py
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
@classmethod
def from_payload(cls, payload: dict[str, Any] | None) -> "ViewSettings":
    """Build ViewSettings from the payload's ``settings`` sub-dict.

    Unknown keys are preserved verbatim in :attr:`extra`.

    Args:
        payload: Settings sub-dict, or ``None`` for defaults.

    Returns:
        A new ViewSettings instance.
    """
    if not payload:
        return cls()

    raw_cols = payload.get("columns") or []
    columns = [
        ColumnState.from_payload(c)
        for c in raw_cols
        if isinstance(c, dict)
    ]

    sort_by = payload.get("sortBy")
    if sort_by is not None:
        sort_by = str(sort_by) or None

    row_height_raw = payload.get("rowHeight", DEFAULT_ROW_HEIGHT)
    try:
        row_height = int(row_height_raw)
    except (TypeError, ValueError):
        row_height = DEFAULT_ROW_HEIGHT

    grouping = GroupingDef(
        group_by=(
            str(payload["groupBy"]) if payload.get("groupBy") else None
        ),
        group_sort_desc=bool(payload.get("groupSortByDesc", False)),
        show_empty_groups=bool(payload.get("showEmptyGroups", False)),
    )

    filter_def = FilterDef.from_payload(payload.get("filter"))

    extra = {
        k: v for k, v in payload.items() if k not in _KNOWN_SETTINGS_KEYS
    }

    return cls(
        columns=columns,
        sort_by=sort_by,
        sort_desc=bool(payload.get("sortDesc", False)),
        row_height=row_height,
        grouping=grouping,
        filter=filter_def,
        extra=extra,
    )

to_payload()

Serialise these settings to a payload dict.

Unknown keys from :attr:extra are merged back in. Known keys in extra are ignored (first-class fields win) so accidental duplication can't corrupt a roundtrip.

Returns:

Type Description
dict[str, Any]

A dict suitable for embedding under "settings" in a

dict[str, Any]

View payload.

Source code in client/ayon_core/ui/components/views/data_models.py
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
def to_payload(self) -> dict[str, Any]:
    """Serialise these settings to a payload dict.

    Unknown keys from :attr:`extra` are merged back in.  Known keys
    in ``extra`` are ignored (first-class fields win) so accidental
    duplication can't corrupt a roundtrip.

    Returns:
        A dict suitable for embedding under ``"settings"`` in a
        View payload.
    """
    out: dict[str, Any] = {
        "columns": [c.to_payload() for c in self.columns],
        "sortDesc": self.sort_desc,
        "rowHeight": self.row_height,
        "groupSortByDesc": self.grouping.group_sort_desc,
        "showEmptyGroups": self.grouping.show_empty_groups,
        "filter": self.filter.to_payload(),
    }
    if self.sort_by is not None:
        out["sortBy"] = self.sort_by
    if self.grouping.group_by is not None:
        out["groupBy"] = self.grouping.group_by

    # Merge extras last but never let them override first-class keys.
    for k, v in self.extra.items():
        if k not in _KNOWN_SETTINGS_KEYS:
            out[k] = v
    return out

Visibility

Bases: str, Enum

Whether a view is owner-only or public to others.

Subclasses str so it serialises naturally to JSON.

Source code in client/ayon_core/ui/components/views/data_models.py
72
73
74
75
76
77
78
79
class Visibility(str, Enum):
    """Whether a view is owner-only or public to others.

    Subclasses ``str`` so it serialises naturally to JSON.
    """

    PRIVATE = "private"
    PUBLIC = "public"