Updated Command to allow client_response. First implementation or UserProfile control
This commit is contained in:
28
README.md
28
README.md
@@ -63,7 +63,7 @@ if __name__ == "__main__":
|
|||||||
```python
|
```python
|
||||||
from fasthtml import serve
|
from fasthtml import serve
|
||||||
|
|
||||||
from myfasthtml.controls.helpers import mk_button
|
from myfasthtml.controls.helpers import mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.myfastapp import create_app
|
from myfasthtml.myfastapp import create_app
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ app, rt = create_app(protect_routes=False)
|
|||||||
|
|
||||||
@rt("/")
|
@rt("/")
|
||||||
def get_homepage():
|
def get_homepage():
|
||||||
return mk_button("Click Me!", command=hello_command)
|
return mk.button("Click Me!", command=hello_command)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -97,11 +97,17 @@ if __name__ == "__main__":
|
|||||||
### Bind components
|
### Bind components
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from myfasthtml.controls.helpers import mk
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Data:
|
class Data:
|
||||||
value: str = "Hello World"
|
value: str = "Hello World"
|
||||||
checked: bool = False
|
checked: bool = False
|
||||||
|
|
||||||
|
|
||||||
# Binds an Input with a label
|
# Binds an Input with a label
|
||||||
mk.mk(Input(name="input_name"), binding=Binding(data, attr="value").htmx(trigger="input changed")),
|
mk.mk(Input(name="input_name"), binding=Binding(data, attr="value").htmx(trigger="input changed")),
|
||||||
mk.mk(Label("Text"), binding=Binding(data, attr="value")),
|
mk.mk(Label("Text"), binding=Binding(data, attr="value")),
|
||||||
@@ -815,6 +821,24 @@ mk.manage_binding(label_elt, Binding(data))
|
|||||||
# Input won't trigger updates, but label will still display data
|
# Input won't trigger updates, but label will still display data
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
session
|
||||||
|
```
|
||||||
|
{'access_token': 'xxx',
|
||||||
|
'refresh_token': 'yyy',
|
||||||
|
'user_info': {
|
||||||
|
'email': 'admin@myauth.com',
|
||||||
|
'username': 'admin',
|
||||||
|
'roles': ['admin'],
|
||||||
|
'user_settings': {},
|
||||||
|
'id': 'uuid',
|
||||||
|
'created_at': '2025-11-10T15:52:59.006213',
|
||||||
|
'updated_at': '2025-11-10T15:52:59.006213'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
We welcome contributions! To get started:
|
We welcome contributions! To get started:
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ dependencies = [
|
|||||||
"email-validator",
|
"email-validator",
|
||||||
"httptools",
|
"httptools",
|
||||||
"myauth",
|
"myauth",
|
||||||
|
"mydbengine",
|
||||||
"myutils",
|
"myutils",
|
||||||
"python-fasthtml",
|
"python-fasthtml",
|
||||||
"PyYAML",
|
"PyYAML",
|
||||||
|
"typer",
|
||||||
"uvloop",
|
"uvloop",
|
||||||
"watchfiles",
|
"watchfiles",
|
||||||
"websockets",
|
"websockets",
|
||||||
|
|||||||
@@ -63,14 +63,12 @@ def auth_before(request, session):
|
|||||||
Args:
|
Args:
|
||||||
request: Starlette request object
|
request: Starlette request object
|
||||||
session: FastHTML session object
|
session: FastHTML session object
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RedirectResponse to login page if authentication fails, None otherwise
|
RedirectResponse to login page if authentication fails, None otherwise
|
||||||
"""
|
"""
|
||||||
# Get tokens from session
|
# Get tokens from session
|
||||||
access_token = session.get('access_token')
|
access_token = session.get('access_token')
|
||||||
refresh_token = session.get('refresh_token')
|
refresh_token = session.get('refresh_token')
|
||||||
print(f"path={request.scope['path']}, {session=}, {access_token=}, {refresh_token=}")
|
|
||||||
# If no access token, redirect to login
|
# If no access token, redirect to login
|
||||||
if not access_token:
|
if not access_token:
|
||||||
return RedirectResponse('/login', status_code=303)
|
return RedirectResponse('/login', status_code=303)
|
||||||
@@ -281,6 +279,23 @@ def get_user_info(access_token: str, base_url: str = None) -> Optional[Dict[str,
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_user_info(access_token: str, user_profile: dict, base_url: str = None):
|
||||||
|
try:
|
||||||
|
response = http_client.patch(
|
||||||
|
f"{base_url or API_BASE_URL}/auth/me",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
timeout=10.0,
|
||||||
|
json=user_profile
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
return None
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def logout_user(refresh_token: str, base_url: str = None) -> bool:
|
def logout_user(refresh_token: str, base_url: str = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Logout user by revoking the refresh token.
|
Logout user by revoking the refresh token.
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ logger = logging.getLogger("LayoutControl")
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LayoutState(DbObject):
|
class LayoutState(DbObject):
|
||||||
def __init__(self, session, owner):
|
def __init__(self, owner):
|
||||||
super().__init__(session, owner.get_id())
|
super().__init__(owner.get_session(), owner.get_id())
|
||||||
|
|
||||||
left_drawer_open: bool = True
|
left_drawer_open: bool = True
|
||||||
right_drawer_open: bool = False
|
right_drawer_open: bool = False
|
||||||
@@ -74,7 +74,7 @@ class Layout(SingleInstance):
|
|||||||
self._header_content = None
|
self._header_content = None
|
||||||
self._footer_content = None
|
self._footer_content = None
|
||||||
self._main_content = None
|
self._main_content = None
|
||||||
self._state = LayoutState(session, self)
|
self._state = LayoutState(self)
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
self.left_drawer = self.DrawerContent(self, "left")
|
self.left_drawer = self.DrawerContent(self, "left")
|
||||||
self.right_drawer = self.DrawerContent(self, "right")
|
self.right_drawer = self.DrawerContent(self, "right")
|
||||||
|
|||||||
@@ -1,15 +1,48 @@
|
|||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import Ids, mk
|
||||||
from myfasthtml.core.instances import SingleInstance
|
from myfasthtml.core.commands import Command
|
||||||
|
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||||
from myfasthtml.core.utils import retrieve_user_info
|
from myfasthtml.core.utils import retrieve_user_info
|
||||||
from myfasthtml.icons.material import dark_mode_filled, person_outline_sharp
|
from myfasthtml.icons.material import dark_mode_filled, person_outline_sharp
|
||||||
from myfasthtml.icons.material_p1 import light_mode_filled, alternate_email_filled
|
from myfasthtml.icons.material_p1 import light_mode_filled, alternate_email_filled
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileState:
|
||||||
|
def __init__(self, owner):
|
||||||
|
self._owner = owner
|
||||||
|
self._session = owner.get_session()
|
||||||
|
|
||||||
|
self.theme = "light"
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
user_info = retrieve_user_info(self._session)
|
||||||
|
user_settings = user_info.get("user_settings", {})
|
||||||
|
for k, v in user_settings.items():
|
||||||
|
if hasattr(self, k):
|
||||||
|
setattr(self, k, v)
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
user_settings = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
|
||||||
|
auth_proxy = InstancesManager.get_auth_proxy()
|
||||||
|
auth_proxy.save_user_info(self._session["access_token"], {"user_settings": user_settings})
|
||||||
|
|
||||||
|
|
||||||
|
class Commands(BaseCommands):
|
||||||
|
def update_dark_mode(self):
|
||||||
|
return Command("UpdateDarkMode", "Set the dark mode", self._owner.update_dark_mode).htmx(target=None)
|
||||||
|
|
||||||
|
|
||||||
class UserProfile(SingleInstance):
|
class UserProfile(SingleInstance):
|
||||||
def __init__(self, session):
|
def __init__(self, session):
|
||||||
super().__init__(session, Ids.UserProfile)
|
super().__init__(session, Ids.UserProfile)
|
||||||
|
self._state = UserProfileState(self)
|
||||||
|
self._commands = Commands(self)
|
||||||
|
|
||||||
|
def update_dark_mode(self, client_response):
|
||||||
|
self._state.theme = client_response.get("theme", "light")
|
||||||
|
self._state.save()
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
user_info = retrieve_user_info(self._session)
|
user_info = retrieve_user_info(self._session)
|
||||||
@@ -29,14 +62,15 @@ class UserProfile(SingleInstance):
|
|||||||
cls="dropdown dropdown-end"
|
cls="dropdown dropdown-end"
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
def mk_dark_mode(self):
|
||||||
def mk_dark_mode():
|
|
||||||
return Label(
|
return Label(
|
||||||
Input(type="checkbox",
|
mk.mk(Input(type="checkbox",
|
||||||
name='theme',
|
name='theme',
|
||||||
aria_label='Dark',
|
aria_label='Dark',
|
||||||
value='dark',
|
value="dark",
|
||||||
cls='theme-controller'),
|
checked='true' if self._state.theme == 'dark' else None,
|
||||||
|
cls='theme-controller'),
|
||||||
|
command=self._commands.update_dark_mode()),
|
||||||
light_mode_filled,
|
light_mode_filled,
|
||||||
dark_mode_filled,
|
dark_mode_filled,
|
||||||
cls="toggle text-base-content"
|
cls="toggle text-base-content"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from myfasthtml.core.utils import merge_classes
|
|||||||
|
|
||||||
|
|
||||||
class Ids:
|
class Ids:
|
||||||
|
AuthProxy = "mf-auth-proxy"
|
||||||
DbManager = "mf-dbmanager"
|
DbManager = "mf-dbmanager"
|
||||||
Layout = "mf-layout"
|
Layout = "mf-layout"
|
||||||
UserProfile = "mf-user-profile"
|
UserProfile = "mf-user-profile"
|
||||||
|
|||||||
18
src/myfasthtml/core/AuthProxy.py
Normal file
18
src/myfasthtml/core/AuthProxy.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from myfasthtml.auth.utils import login_user, save_user_info, register_user
|
||||||
|
from myfasthtml.controls.helpers import Ids
|
||||||
|
from myfasthtml.core.instances import special_session, UniqueInstance
|
||||||
|
|
||||||
|
|
||||||
|
class AuthProxy(UniqueInstance):
|
||||||
|
def __init__(self, base_url: str = None):
|
||||||
|
super().__init__(special_session, Ids.AuthProxy)
|
||||||
|
self._base_url = base_url
|
||||||
|
|
||||||
|
def login_user(self, email: str, password: str):
|
||||||
|
return login_user(email, password, self._base_url)
|
||||||
|
|
||||||
|
def register_user(self, email: str, username: str, password: str):
|
||||||
|
return register_user(email, username, password, self._base_url)
|
||||||
|
|
||||||
|
def save_user_info(self, access_token: str, user_profile: dict):
|
||||||
|
return save_user_info(access_token, user_profile, self._base_url)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import inspect
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ class BaseCommand:
|
|||||||
"hx-vals": f'{{"c_id": "{self.id}"}}',
|
"hx-vals": f'{{"c_id": "{self.id}"}}',
|
||||||
} | self._htmx_extra
|
} | self._htmx_extra
|
||||||
|
|
||||||
def execute(self):
|
def execute(self, client_response: dict = None):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def htmx(self, target="this", swap="innerHTML"):
|
def htmx(self, target="this", swap="innerHTML"):
|
||||||
@@ -84,6 +85,9 @@ class BaseCommand:
|
|||||||
self._htmx_extra["hx-swap"] = "none"
|
self._htmx_extra["hx-swap"] = "none"
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Command({self.name})"
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -110,8 +114,9 @@ class Command(BaseCommand):
|
|||||||
self.callback = callback
|
self.callback = callback
|
||||||
self.args = args
|
self.args = args
|
||||||
self.kwargs = kwargs
|
self.kwargs = kwargs
|
||||||
|
self.requires_client_response = 'client_response' in inspect.signature(callback).parameters
|
||||||
|
|
||||||
def execute(self):
|
def execute(self, client_response: dict = None):
|
||||||
ret_from_bindings = []
|
ret_from_bindings = []
|
||||||
|
|
||||||
def binding_result_callback(attr, old, new, results):
|
def binding_result_callback(attr, old, new, results):
|
||||||
@@ -120,7 +125,10 @@ class Command(BaseCommand):
|
|||||||
for data in self._bindings:
|
for data in self._bindings:
|
||||||
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
|
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
|
||||||
|
|
||||||
ret = self.callback(*self.args, **self.kwargs)
|
if self.requires_client_response:
|
||||||
|
ret = self.callback(client_response=client_response, *self.args, **self.kwargs)
|
||||||
|
else:
|
||||||
|
ret = self.callback(*self.args, **self.kwargs)
|
||||||
|
|
||||||
for data in self._bindings:
|
for data in self._bindings:
|
||||||
remove_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
|
remove_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
|
||||||
@@ -138,9 +146,6 @@ class Command(BaseCommand):
|
|||||||
return list(ret) + ret_from_bindings
|
return list(ret) + ret_from_bindings
|
||||||
else:
|
else:
|
||||||
return [ret] + ret_from_bindings
|
return [ret] + ret_from_bindings
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Command({self.name})"
|
|
||||||
|
|
||||||
|
|
||||||
class CommandsManager:
|
class CommandsManager:
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from myfasthtml.controls.helpers import Ids
|
||||||
|
|
||||||
|
special_session = {
|
||||||
|
"user_info": {"id": "** SPECIAL SESSION **"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DuplicateInstanceError(Exception):
|
class DuplicateInstanceError(Exception):
|
||||||
def __init__(self, instance):
|
def __init__(self, instance):
|
||||||
@@ -34,6 +40,16 @@ class SingleInstance(BaseInstance):
|
|||||||
self._instance = None
|
self._instance = None
|
||||||
|
|
||||||
|
|
||||||
|
class UniqueInstance(BaseInstance):
|
||||||
|
"""
|
||||||
|
Base class for instances that can only have one instance at a time.
|
||||||
|
Does not throw exception if the instance already exists, it simply overwrites it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: dict, prefix: str, auto_register: bool = True):
|
||||||
|
super().__init__(session, prefix, auto_register)
|
||||||
|
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.
|
||||||
@@ -79,7 +95,10 @@ class InstancesManager:
|
|||||||
|
|
||||||
return InstancesManager.instances[key]
|
return InstancesManager.instances[key]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return instance_type(session, *args, **kwargs) # it will be automatically registered
|
if instance_type:
|
||||||
|
return instance_type(session, *args, **kwargs) # it will be automatically registered
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_session_id(session):
|
def _get_session_id(session):
|
||||||
@@ -88,3 +107,7 @@ class InstancesManager:
|
|||||||
if "user_info" not in session:
|
if "user_info" not in session:
|
||||||
return "** UNKNOWN USER **"
|
return "** UNKNOWN USER **"
|
||||||
return session["user_info"].get("id", "** INVALID SESSION **")
|
return session["user_info"].get("id", "** INVALID SESSION **")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_auth_proxy():
|
||||||
|
return InstancesManager.get(special_session, Ids.AuthProxy)
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ def retrieve_user_info(session: dict):
|
|||||||
"email": "** NOT LOGGED IN **",
|
"email": "** NOT LOGGED IN **",
|
||||||
"username": "** NOT LOGGED IN **",
|
"username": "** NOT LOGGED IN **",
|
||||||
"role": [],
|
"role": [],
|
||||||
|
"user_settings": {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if "user_info" not in session:
|
if "user_info" not in session:
|
||||||
@@ -206,24 +207,37 @@ def retrieve_user_info(session: dict):
|
|||||||
"email": "** UNKNOWN USER **",
|
"email": "** UNKNOWN USER **",
|
||||||
"username": "** UNKNOWN USER **",
|
"username": "** UNKNOWN USER **",
|
||||||
"role": [],
|
"role": [],
|
||||||
|
"user_settings": {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return session["user_info"]
|
return session["user_info"]
|
||||||
|
|
||||||
|
|
||||||
|
def debug_session(session):
|
||||||
|
if session is None:
|
||||||
|
return "None"
|
||||||
|
|
||||||
|
if not isinstance(session, dict):
|
||||||
|
return str(session)
|
||||||
|
|
||||||
|
return session.get("user_info", {}).get("email", "** UNKNOWN USER **")
|
||||||
|
|
||||||
|
|
||||||
@utils_rt(Routes.Commands)
|
@utils_rt(Routes.Commands)
|
||||||
def post(session, c_id: str):
|
def post(session, c_id: str, client_response: dict = None):
|
||||||
"""
|
"""
|
||||||
Default routes for all commands.
|
Default routes for all commands.
|
||||||
:param session:
|
:param session:
|
||||||
:param c_id:
|
:param c_id: id of the command set
|
||||||
|
:param client_response: extra data received from the client (from the browser)
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
logger.debug(f"Entering {Routes.Commands} with {session=}, {c_id=}")
|
client_response.pop("c_id", None)
|
||||||
|
logger.debug(f"Entering {Routes.Commands} with session='{debug_session(session)}', {c_id=}, {client_response=}")
|
||||||
from myfasthtml.core.commands import CommandsManager
|
from myfasthtml.core.commands import CommandsManager
|
||||||
command = CommandsManager.get_command(c_id)
|
command = CommandsManager.get_command(c_id)
|
||||||
if command:
|
if command:
|
||||||
return command.execute()
|
return command.execute(client_response)
|
||||||
|
|
||||||
raise ValueError(f"Command with ID '{c_id}' not found.")
|
raise ValueError(f"Command with ID '{c_id}' not found.")
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from starlette.responses import Response
|
|||||||
|
|
||||||
from myfasthtml.auth.routes import setup_auth_routes
|
from myfasthtml.auth.routes import setup_auth_routes
|
||||||
from myfasthtml.auth.utils import create_auth_beforeware
|
from myfasthtml.auth.utils import create_auth_beforeware
|
||||||
|
from myfasthtml.core.AuthProxy import AuthProxy
|
||||||
from myfasthtml.core.utils import utils_app
|
from myfasthtml.core.utils import utils_app
|
||||||
|
|
||||||
logger = logging.getLogger("MyFastHtml")
|
logger = logging.getLogger("MyFastHtml")
|
||||||
@@ -96,4 +97,7 @@ def create_app(daisyui: Optional[bool] = True,
|
|||||||
# Setup authentication routes
|
# Setup authentication routes
|
||||||
setup_auth_routes(app, rt, base_url=base_url)
|
setup_auth_routes(app, rt, base_url=base_url)
|
||||||
|
|
||||||
|
# create the AuthProxy instance
|
||||||
|
AuthProxy(base_url) # using the auto register mechanism to expose it
|
||||||
|
|
||||||
return app, rt
|
return app, rt
|
||||||
|
|||||||
Reference in New Issue
Block a user