Updated Command to allow client_response. First implementation or UserProfile control

This commit is contained in:
2025-11-11 12:07:00 +01:00
parent c641f3fd63
commit cba4f2aab4
11 changed files with 166 additions and 26 deletions

View File

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

View File

@@ -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",

View File

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

View File

@@ -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")

View File

@@ -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",
checked='true' if self._state.theme == 'dark' else None,
cls='theme-controller'), 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"

View File

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

View 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)

View File

@@ -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"):
@@ -85,6 +86,9 @@ class BaseCommand:
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,6 +125,9 @@ 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)
if self.requires_client_response:
ret = self.callback(client_response=client_response, *self.args, **self.kwargs)
else:
ret = self.callback(*self.args, **self.kwargs) ret = self.callback(*self.args, **self.kwargs)
for data in self._bindings: for data in self._bindings:
@@ -139,9 +147,6 @@ class Command(BaseCommand):
else: else:
return [ret] + ret_from_bindings return [ret] + ret_from_bindings
def __str__(self):
return f"Command({self.name})"
class CommandsManager: class CommandsManager:
commands = {} commands = {}

View File

@@ -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:
if instance_type:
return instance_type(session, *args, **kwargs) # it will be automatically registered 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)

View File

@@ -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.")

View File

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