I can display tabsmanager
This commit is contained in:
@@ -4,6 +4,7 @@ from fasthtml import serve
|
|||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
from myfasthtml.controls.Layout import Layout
|
from myfasthtml.controls.Layout import Layout
|
||||||
|
from myfasthtml.controls.TabsManager import TabsManager
|
||||||
from myfasthtml.controls.helpers import Ids
|
from myfasthtml.controls.helpers import Ids
|
||||||
from myfasthtml.core.instances import InstancesManager
|
from myfasthtml.core.instances import InstancesManager
|
||||||
from myfasthtml.myfastapp import create_app
|
from myfasthtml.myfastapp import create_app
|
||||||
@@ -29,8 +30,11 @@ def index(session):
|
|||||||
for i in range(1000):
|
for i in range(1000):
|
||||||
layout.left_drawer.append(Div(f"Left Drawer Item {i}"))
|
layout.left_drawer.append(Div(f"Left Drawer Item {i}"))
|
||||||
|
|
||||||
content = tuple([Div(f"Content {i}") for i in range(1000)])
|
tabs_manager = InstancesManager.get(session, Ids.TabsManager, TabsManager)
|
||||||
layout.set_main(content)
|
tabs_manager.add_tab("Users", Div("Content 1"))
|
||||||
|
tabs_manager.add_tab("Users", Div("Content 2"))
|
||||||
|
tabs_manager.add_tab("Users", Div("Content 3"))
|
||||||
|
layout.set_main(tabs_manager)
|
||||||
return layout
|
return layout
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
grid-area: main;
|
grid-area: main;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding: 1rem;
|
padding: 0.5rem;
|
||||||
background-color: var(--color-base-100);
|
background-color: var(--color-base-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,4 +169,88 @@
|
|||||||
"main"
|
"main"
|
||||||
"footer";
|
"footer";
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs Manager Container */
|
||||||
|
.mf-tabs-manager {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--color-base-200);
|
||||||
|
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||||
|
border-radius: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs Header using DaisyUI tabs component */
|
||||||
|
.mf-tabs-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual Tab Button using DaisyUI tab classes */
|
||||||
|
.mf-tab-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0 0.5rem 0 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-tab-button:hover {
|
||||||
|
color: var(--color-base-content); /* Change text color on hover */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-tab-button.mf-tab-active {
|
||||||
|
--depth: 1;
|
||||||
|
background-color: var(--color-base-100);
|
||||||
|
color: var(--color-base-content);
|
||||||
|
border-radius: .25rem;
|
||||||
|
box-shadow: 0 1px oklch(100% 0 0/calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab Label */
|
||||||
|
.mf-tab-label {
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab Close Button */
|
||||||
|
.mf-tab-close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
@apply text-base-content/50;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-tab-close-btn:hover {
|
||||||
|
@apply bg-base-300 text-error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab Content Area */
|
||||||
|
.mf-tab-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
background-color: var(--color-base-100);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty Content State */
|
||||||
|
.mf-empty-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
@apply text-base-content/50;
|
||||||
|
font-style: italic;
|
||||||
}
|
}
|
||||||
@@ -21,13 +21,12 @@ from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_dra
|
|||||||
logger = logging.getLogger("LayoutControl")
|
logger = logging.getLogger("LayoutControl")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LayoutState(DbObject):
|
class LayoutState(DbObject):
|
||||||
def __init__(self, owner):
|
def __init__(self, owner):
|
||||||
super().__init__(owner.get_session(), owner.get_id())
|
super().__init__(owner.get_session(), owner.get_id())
|
||||||
|
with self.initializing():
|
||||||
left_drawer_open: bool = True
|
self.left_drawer_open: bool = True
|
||||||
right_drawer_open: bool = False
|
self.right_drawer_open: bool = False
|
||||||
|
|
||||||
|
|
||||||
class Commands(BaseCommands):
|
class Commands(BaseCommands):
|
||||||
|
|||||||
185
src/myfasthtml/controls/TabsManager.py
Normal file
185
src/myfasthtml/controls/TabsManager.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fasthtml.common import Div, Button, Span
|
||||||
|
|
||||||
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
|
from myfasthtml.controls.helpers import Ids
|
||||||
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
|
from myfasthtml.core.instances import MultipleInstance, BaseInstance
|
||||||
|
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular
|
||||||
|
|
||||||
|
|
||||||
|
# Structure de tabs:
|
||||||
|
# {
|
||||||
|
# "tab-uuid-1": {
|
||||||
|
# "label": "Users",
|
||||||
|
# "component_type": "UsersPanel",
|
||||||
|
# "component_id": "UsersPanel-abc123",
|
||||||
|
# },
|
||||||
|
# "tab-uuid-2": { ... }
|
||||||
|
# }
|
||||||
|
|
||||||
|
class TabsManagerState(DbObject):
|
||||||
|
def __init__(self, owner):
|
||||||
|
super().__init__(owner.get_session(), owner.get_id())
|
||||||
|
with self.initializing():
|
||||||
|
# persisted in DB
|
||||||
|
self.tabs: dict[str, Any] = {}
|
||||||
|
self.tabs_order: list[str] = []
|
||||||
|
self.active_tab: str | None = None
|
||||||
|
|
||||||
|
# must not be persisted in DB
|
||||||
|
self._tabs_content: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class Commands(BaseCommands):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TabsManager(MultipleInstance):
|
||||||
|
def __init__(self, session):
|
||||||
|
super().__init__(session, Ids.TabsManager)
|
||||||
|
self._state = TabsManagerState(self)
|
||||||
|
self._commands = Commands(self)
|
||||||
|
|
||||||
|
def get_state(self):
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
def add_tab(self, label: str, component: Any, activate: bool = True) -> str:
|
||||||
|
"""
|
||||||
|
Add a new tab or update an existing one with the same component type, ID and label.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
label: Display label for the tab
|
||||||
|
component: Component instance to display in the tab
|
||||||
|
activate: Whether to activate the new/updated tab immediately (default: True)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tab_id: The UUID of the tab (new or existing)
|
||||||
|
"""
|
||||||
|
# copy the state to avoid multiple database call
|
||||||
|
state = self._state.copy()
|
||||||
|
|
||||||
|
# Extract component ID if the component has a get_id() method
|
||||||
|
component_type, component_id = None, None
|
||||||
|
if isinstance(component, BaseInstance):
|
||||||
|
component_type = type(component).__name__
|
||||||
|
component_id = component.get_id()
|
||||||
|
|
||||||
|
# Check if a tab with the same component_type, component_id AND label already exists
|
||||||
|
existing_tab_id = None
|
||||||
|
if component_id is not None:
|
||||||
|
for tab_id, tab_data in state.tabs.items():
|
||||||
|
if (tab_data.get('component_type') == component_type and
|
||||||
|
tab_data.get('component_id') == component_id and
|
||||||
|
tab_data.get('label') == label):
|
||||||
|
existing_tab_id = tab_id
|
||||||
|
break
|
||||||
|
|
||||||
|
if existing_tab_id:
|
||||||
|
# Update existing tab (only the component instance in memory)
|
||||||
|
tab_id = existing_tab_id
|
||||||
|
state._tabs_content[tab_id] = component
|
||||||
|
else:
|
||||||
|
# Create new tab
|
||||||
|
tab_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Add tab metadata to state
|
||||||
|
state.tabs[tab_id] = {
|
||||||
|
'label': label,
|
||||||
|
'component_type': component_type,
|
||||||
|
'component_id': component_id
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add tab to order
|
||||||
|
state.tabs_order.append(tab_id)
|
||||||
|
|
||||||
|
# Store component in memory
|
||||||
|
state._tabs_content[tab_id] = component
|
||||||
|
|
||||||
|
# Activate tab if requested
|
||||||
|
if activate:
|
||||||
|
state.active_tab = tab_id
|
||||||
|
|
||||||
|
# finally, update the state
|
||||||
|
self._state.update(state)
|
||||||
|
|
||||||
|
return tab_id
|
||||||
|
|
||||||
|
def _mk_tab_button(self, tab_id: str, tab_data: dict):
|
||||||
|
"""
|
||||||
|
Create a single tab button with its label and close button.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tab_id: Unique identifier for the tab
|
||||||
|
tab_data: Dictionary containing tab information (label, component_type, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Button element representing the tab
|
||||||
|
"""
|
||||||
|
is_active = tab_id == self._state.active_tab
|
||||||
|
|
||||||
|
return Button(
|
||||||
|
Span(tab_data.get("label", "Untitled"), cls="mf-tab-label"),
|
||||||
|
Span(dismiss_circle16_regular, cls="mf-tab-close-btn"),
|
||||||
|
cls=f"mf-tab-button {'mf-tab-active' if is_active else ''}",
|
||||||
|
data_tab_id=tab_id,
|
||||||
|
data_manager_id=self._id
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mk_tabs_header(self):
|
||||||
|
"""
|
||||||
|
Create the tabs header containing all tab buttons.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Div element containing all tab buttons
|
||||||
|
"""
|
||||||
|
tab_buttons = [
|
||||||
|
self._mk_tab_button(tab_id, self._state.tabs[tab_id])
|
||||||
|
for tab_id in self._state.tabs_order
|
||||||
|
if tab_id in self._state.tabs
|
||||||
|
]
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
*tab_buttons,
|
||||||
|
cls="mf-tabs-header",
|
||||||
|
id=f"{self._id}-header"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mk_tab_content(self):
|
||||||
|
"""
|
||||||
|
Create the active tab content area.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Div element containing the active tab content or empty container
|
||||||
|
"""
|
||||||
|
content = None
|
||||||
|
|
||||||
|
if self._state.active_tab and self._state.active_tab in self._state._tabs_content:
|
||||||
|
component = self._state._tabs_content[self._state.active_tab]
|
||||||
|
content = component
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
content if content else Div("No active tab", cls="mf-empty-content"),
|
||||||
|
cls="mf-tab-content",
|
||||||
|
id=f"{self._id}-content"
|
||||||
|
)
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
"""
|
||||||
|
Render the complete TabsManager component.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Div element containing tabs header and content area
|
||||||
|
"""
|
||||||
|
return Div(
|
||||||
|
self._mk_tabs_header(),
|
||||||
|
self._mk_tab_content(),
|
||||||
|
cls="mf-tabs-manager",
|
||||||
|
id=self._id
|
||||||
|
)
|
||||||
|
|
||||||
|
def __ft__(self):
|
||||||
|
return self.render()
|
||||||
@@ -6,9 +6,11 @@ from myfasthtml.core.utils import merge_classes
|
|||||||
|
|
||||||
|
|
||||||
class Ids:
|
class Ids:
|
||||||
|
# Please keep the alphabetical order
|
||||||
AuthProxy = "mf-auth-proxy"
|
AuthProxy = "mf-auth-proxy"
|
||||||
DbManager = "mf-dbmanager"
|
DbManager = "mf-dbmanager"
|
||||||
Layout = "mf-layout"
|
Layout = "mf-layout"
|
||||||
|
TabsManager = "mf-tabs-manager"
|
||||||
UserProfile = "mf-user-profile"
|
UserProfile = "mf-user-profile"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
from contextlib import contextmanager
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from dbengine.dbengine import DbEngine
|
from dbengine.dbengine import DbEngine
|
||||||
|
|
||||||
from myfasthtml.controls.helpers import Ids
|
from myfasthtml.controls.helpers import Ids
|
||||||
@@ -31,6 +34,8 @@ class DbObject:
|
|||||||
When you set the attribute, it persists in DB
|
When you set the attribute, it persists in DB
|
||||||
It loads from DB at startup
|
It loads from DB at startup
|
||||||
"""
|
"""
|
||||||
|
_initializing = False
|
||||||
|
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_session", "_forbidden_attrs"}
|
||||||
|
|
||||||
def __init__(self, session, name=None, db_manager=None):
|
def __init__(self, session, name=None, db_manager=None):
|
||||||
self._session = session
|
self._session = session
|
||||||
@@ -41,12 +46,23 @@ class DbObject:
|
|||||||
if self._db_manager.exists_entry(self._name):
|
if self._db_manager.exists_entry(self._name):
|
||||||
props = self._db_manager.load(self._name)
|
props = self._db_manager.load(self._name)
|
||||||
for k, v in props.items():
|
for k, v in props.items():
|
||||||
setattr(self, k, v)
|
if hasattr(self, k):
|
||||||
|
setattr(self, k, v)
|
||||||
else:
|
else:
|
||||||
self._save_self()
|
self._save_self()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def initializing(self):
|
||||||
|
old_state = getattr(self, "_initializing", False)
|
||||||
|
self._initializing = True
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self._initializing = old_state
|
||||||
|
self._save_self()
|
||||||
|
|
||||||
def __setattr__(self, name: str, value: str):
|
def __setattr__(self, name: str, value: str):
|
||||||
if name.startswith("_"):
|
if name.startswith("_") or getattr(self, "_initializing", False):
|
||||||
super().__setattr__(name, value)
|
super().__setattr__(name, value)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -58,5 +74,42 @@ class DbObject:
|
|||||||
self._save_self()
|
self._save_self()
|
||||||
|
|
||||||
def _save_self(self):
|
def _save_self(self):
|
||||||
props = {k: getattr(self, k) for k, v in self.__class__.__dict__.items() if not k.startswith("_")}
|
props = {k: getattr(self, k) for k, v in self._get_properties().items() if not k.startswith("_")}
|
||||||
self._db_manager.save(self._name, props)
|
if props:
|
||||||
|
self._db_manager.save(self._name, props)
|
||||||
|
|
||||||
|
def _get_properties(self):
|
||||||
|
"""
|
||||||
|
Retrieves all the properties of the current object, combining both the properties defined in
|
||||||
|
the class and the instance attributes.
|
||||||
|
:return: A dictionary containing the properties of the object, where keys are property names
|
||||||
|
and values are their corresponding values.
|
||||||
|
"""
|
||||||
|
props = {k: getattr(self, k) for k, v in self.__class__.__dict__.items()} # for dataclass
|
||||||
|
props |= {k: getattr(self, k) for k, v in self.__dict__.items()} # for dataclass
|
||||||
|
props = {k: v for k, v in props.items() if not k.startswith("__")}
|
||||||
|
return props
|
||||||
|
|
||||||
|
def update(self, *args, **kwargs):
|
||||||
|
if len(args) > 1:
|
||||||
|
raise ValueError("Only one argument is allowed")
|
||||||
|
|
||||||
|
properties = {}
|
||||||
|
if args:
|
||||||
|
arg = args[0]
|
||||||
|
if not isinstance(arg, (dict, SimpleNamespace)):
|
||||||
|
raise ValueError("Only dict or Expando are allowed as argument")
|
||||||
|
properties |= vars(arg) if isinstance(arg, SimpleNamespace) else arg
|
||||||
|
|
||||||
|
properties |= kwargs
|
||||||
|
|
||||||
|
with self.initializing():
|
||||||
|
for k, v in properties.items():
|
||||||
|
if hasattr(self, k) and k not in DbObject._forbidden_attrs: # internal variables cannot be updated
|
||||||
|
setattr(self, k, v)
|
||||||
|
self._save_self()
|
||||||
|
|
||||||
|
def copy(self):
|
||||||
|
as_dict = self._get_properties().copy()
|
||||||
|
as_dict = {k: v for k, v in as_dict.items() if k not in DbObject._forbidden_attrs}
|
||||||
|
return SimpleNamespace(**as_dict)
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class UniqueInstance(BaseInstance):
|
|||||||
super().__init__(session, prefix, auto_register)
|
super().__init__(session, prefix, auto_register)
|
||||||
self._instance = None
|
self._instance = None
|
||||||
|
|
||||||
|
|
||||||
class MultipleInstance(BaseInstance):
|
class MultipleInstance(BaseInstance):
|
||||||
"""
|
"""
|
||||||
Base class for instances that can have multiple instances at a time.
|
Base class for instances that can have multiple instances at a time.
|
||||||
@@ -111,3 +112,7 @@ class InstancesManager:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def get_auth_proxy():
|
def get_auth_proxy():
|
||||||
return InstancesManager.get(special_session, Ids.AuthProxy)
|
return InstancesManager.get(special_session, Ids.AuthProxy)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reset():
|
||||||
|
return InstancesManager.instances.clear()
|
||||||
|
|||||||
16
tests/controls/conftest.py
Normal file
16
tests/controls/conftest.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def session():
|
||||||
|
return {
|
||||||
|
"user_info": {
|
||||||
|
'email': 'test@myfasthtml.com',
|
||||||
|
'username': 'test_user',
|
||||||
|
'roles': ['admin'],
|
||||||
|
'user_settings': {},
|
||||||
|
'id': 'test_user_id',
|
||||||
|
'created_at': '2025-11-10T15:52:59.006213',
|
||||||
|
'updated_at': '2025-11-10T15:52:59.006213'
|
||||||
|
}
|
||||||
|
}
|
||||||
30
tests/controls/test_tabsmanager.py
Normal file
30
tests/controls/test_tabsmanager.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import pytest
|
||||||
|
from fasthtml.components import *
|
||||||
|
|
||||||
|
from myfasthtml.controls.TabsManager import TabsManager
|
||||||
|
from myfasthtml.core.instances import InstancesManager
|
||||||
|
from .conftest import session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def tabs_manager(session):
|
||||||
|
yield TabsManager(session)
|
||||||
|
|
||||||
|
InstancesManager.reset()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tabs_manager_is_registered(session, tabs_manager):
|
||||||
|
from_instance_manager = InstancesManager.get(session, tabs_manager.get_id())
|
||||||
|
assert from_instance_manager == tabs_manager
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_add_tab(tabs_manager):
|
||||||
|
tab_id = tabs_manager.add_tab("Users", Div("Content 1"))
|
||||||
|
|
||||||
|
assert tab_id is not None
|
||||||
|
assert tab_id in tabs_manager.get_state().tabs
|
||||||
|
assert tabs_manager.get_state().tabs[tab_id]["label"] == "Users"
|
||||||
|
assert tabs_manager.get_state().tabs[tab_id]["component_type"] is None # Div is not BaseInstance
|
||||||
|
assert tabs_manager.get_state().tabs[tab_id]["component_id"] is None # Div is not BaseInstance
|
||||||
|
assert tabs_manager.get_state().tabs_order == [tab_id]
|
||||||
|
assert tabs_manager.get_state().active_tab == tab_id
|
||||||
@@ -33,6 +33,26 @@ def simplify(res: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def test_i_can_init(session, db_manager):
|
def test_i_can_init(session, db_manager):
|
||||||
|
class DummyObject(DbObject):
|
||||||
|
def __init__(self, sess: dict):
|
||||||
|
super().__init__(sess, "DummyObject", db_manager)
|
||||||
|
|
||||||
|
with self.initializing():
|
||||||
|
self.value: str = "hello"
|
||||||
|
self.number: int = 42
|
||||||
|
self.none_value: None = None
|
||||||
|
|
||||||
|
dummy = DummyObject(session)
|
||||||
|
|
||||||
|
props = dummy._get_properties()
|
||||||
|
|
||||||
|
in_db = db_manager.load("DummyObject")
|
||||||
|
history = db_manager.db.history(db_manager.get_tenant(), "DummyObject")
|
||||||
|
assert simplify(in_db) == {"value": "hello", "number": 42, "none_value": None}
|
||||||
|
assert len(history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_init_from_dataclass(session, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, sess: dict):
|
||||||
@@ -40,10 +60,14 @@ def test_i_can_init(session, db_manager):
|
|||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
|
none_value: None = None
|
||||||
|
|
||||||
DummyObject(session)
|
DummyObject(session)
|
||||||
|
|
||||||
assert simplify(db_manager.load("DummyObject")) == {"value": "hello", "number": 42}
|
in_db = db_manager.load("DummyObject")
|
||||||
|
history = db_manager.db.history(db_manager.get_tenant(), "DummyObject")
|
||||||
|
assert simplify(in_db) == {"value": "hello", "number": 42, "none_value": None}
|
||||||
|
assert len(history) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_init_from_db(session, db_manager):
|
def test_i_can_init_from_db(session, db_manager):
|
||||||
@@ -96,3 +120,77 @@ def test_i_do_not_save_in_db_when_value_is_the_same(session, db_manager):
|
|||||||
in_db_2 = db_manager.load("DummyObject")
|
in_db_2 = db_manager.load("DummyObject")
|
||||||
|
|
||||||
assert in_db_1["__parent__"] == in_db_2["__parent__"]
|
assert in_db_1["__parent__"] == in_db_2["__parent__"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_update(session, db_manager):
|
||||||
|
@dataclass
|
||||||
|
class DummyObject(DbObject):
|
||||||
|
def __init__(self, sess: dict):
|
||||||
|
super().__init__(sess, "DummyObject", db_manager)
|
||||||
|
|
||||||
|
value: str = "hello"
|
||||||
|
number: int = 42
|
||||||
|
|
||||||
|
dummy = DummyObject(session)
|
||||||
|
clone = dummy.copy()
|
||||||
|
|
||||||
|
clone.number = 34
|
||||||
|
clone.value = "other_value"
|
||||||
|
clone.other_attr = "some_value"
|
||||||
|
|
||||||
|
dummy.update(clone)
|
||||||
|
|
||||||
|
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 34}
|
||||||
|
|
||||||
|
|
||||||
|
def test_forbidden_attributes_are_not_the_copy(session, db_manager):
|
||||||
|
class DummyObject(DbObject):
|
||||||
|
def __init__(self, sess: dict):
|
||||||
|
super().__init__(sess, "DummyObject", db_manager)
|
||||||
|
|
||||||
|
with self.initializing():
|
||||||
|
self.value: str = "hello"
|
||||||
|
self.number: int = 42
|
||||||
|
self.none_value: None = None
|
||||||
|
|
||||||
|
dummy = DummyObject(session)
|
||||||
|
|
||||||
|
clone = dummy.copy()
|
||||||
|
|
||||||
|
for k in DbObject._forbidden_attrs:
|
||||||
|
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_forbidden_attributes_are_not_the_copy_for_dataclass(session, db_manager):
|
||||||
|
@dataclass
|
||||||
|
class DummyObject(DbObject):
|
||||||
|
def __init__(self, sess: dict):
|
||||||
|
super().__init__(sess, "DummyObject", db_manager)
|
||||||
|
|
||||||
|
value: str = "hello"
|
||||||
|
number: int = 42
|
||||||
|
none_value: None = None
|
||||||
|
|
||||||
|
dummy = DummyObject(session)
|
||||||
|
|
||||||
|
clone = dummy.copy()
|
||||||
|
|
||||||
|
for k in DbObject._forbidden_attrs:
|
||||||
|
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_cannot_update_a_forbidden_attribute(session, db_manager):
|
||||||
|
@dataclass
|
||||||
|
class DummyObject(DbObject):
|
||||||
|
def __init__(self, sess: dict):
|
||||||
|
super().__init__(sess, "DummyObject", db_manager)
|
||||||
|
|
||||||
|
value: str = "hello"
|
||||||
|
number: int = 42
|
||||||
|
none_value: None = None
|
||||||
|
|
||||||
|
dummy = DummyObject(session)
|
||||||
|
|
||||||
|
dummy.update(_session="other_value")
|
||||||
|
|
||||||
|
assert dummy._session == session
|
||||||
|
|||||||
Reference in New Issue
Block a user