Created
July 23, 2025 04:51
-
-
Save thomasht86/ea8c9529d1fa5ddc2e132f41ae765892 to your computer and use it in GitHub Desktop.
Generic FastHTML Admin UI (WIP)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # main.py | |
| from fasthtml.common import * | |
| from dataclasses import dataclass, field, fields | |
| from datetime import date | |
| from typing import Text | |
| # 1. App Initialization | |
| # fast_app() provides the app and route decorator objects. | |
| # hdrs can be used to add global CSS or JS. PicoCSS is included by default. | |
| app, rt = fast_app() | |
| # 2. Model Definitions | |
| # These dataclasses define the schema for our application. | |
| # The 'pk' metadata is a custom convention for our app to identify the primary key. | |
| @dataclass | |
| class User: | |
| username: str | |
| email: str | |
| id: int = field(default=None, metadata={"pk": True}) | |
| is_active: bool = True | |
| created_date: date = field(default_factory=date.today) | |
| @dataclass | |
| class Vendor: | |
| name: str | |
| contact_person: str | |
| phone_number: str | |
| id: int = field(default=None, metadata={"pk": True}) | |
| is_preferred: bool = False | |
| @dataclass | |
| class Product: | |
| name: str | |
| vendor_id: int | |
| sku: str | |
| price: float | |
| stock_quantity: int | |
| id: int = field(default=None, metadata={"pk": True}) | |
| description: Text = "" | |
| # 3. Global State and Registry | |
| MODEL_REGISTRY = {} | |
| MODELS_TO_REGISTER = [User, Vendor, Product] | |
| db = database("data/admin_ui.db") | |
| # 4. Database Setup | |
| def setup_database_and_models(): | |
| """Dynamically creates tables for registered models if they don't exist.""" | |
| for model_cls in MODELS_TO_REGISTER: | |
| model_name_plural = model_cls.__name__.lower() + "s" | |
| table = getattr(db.t, model_name_plural) | |
| if table not in db.t: | |
| print(f"Creating table for {model_cls.__name__}...") | |
| cols = {f.name: f.type for f in fields(model_cls)} | |
| pk_field = next( | |
| (f.name for f in fields(model_cls) if f.metadata.get("pk")), "id" | |
| ) | |
| table.create(**cols, pk=pk_field) | |
| MODEL_REGISTRY[model_name_plural] = (model_cls, table) | |
| print("Database setup complete. Registered models:", list(MODEL_REGISTRY.keys())) | |
| # Add sample data if tables are empty | |
| create_sample_data() | |
| def create_sample_data(): | |
| """Creates sample data for development and testing.""" | |
| users_table = MODEL_REGISTRY["users"][1] | |
| vendors_table = MODEL_REGISTRY["vendors"][1] | |
| products_table = MODEL_REGISTRY["products"][1] | |
| # Add sample users if empty | |
| if len(users_table()) == 0: | |
| sample_users = [ | |
| User(username="admin", email="admin@example.com", is_active=True), | |
| User(username="john_doe", email="john@example.com", is_active=True), | |
| User(username="jane_smith", email="jane@example.com", is_active=False), | |
| ] | |
| for user in sample_users: | |
| users_table.insert(user) | |
| print("Added sample users") | |
| # Add sample vendors if empty | |
| if len(vendors_table()) == 0: | |
| sample_vendors = [ | |
| Vendor( | |
| name="Local Farm Co", | |
| contact_person="Ole Hansen", | |
| phone_number="12345678", | |
| is_preferred=True, | |
| ), | |
| Vendor( | |
| name="Bergen Bakery", | |
| contact_person="Astrid Larsen", | |
| phone_number="87654321", | |
| is_preferred=False, | |
| ), | |
| Vendor( | |
| name="Trondheim Meat", | |
| contact_person="Erik Nordahl", | |
| phone_number="11223344", | |
| is_preferred=True, | |
| ), | |
| ] | |
| for vendor in sample_vendors: | |
| vendors_table.insert(vendor) | |
| print("Added sample vendors") | |
| # Add sample products if empty | |
| if len(products_table()) == 0: | |
| sample_products = [ | |
| Product( | |
| name="Organic Potatoes", | |
| vendor_id=1, | |
| sku="ORG-POT-001", | |
| price=29.99, | |
| stock_quantity=100, | |
| description="Fresh organic potatoes from local farm", | |
| ), | |
| Product( | |
| name="Sourdough Bread", | |
| vendor_id=2, | |
| sku="BREAD-SD-001", | |
| price=45.00, | |
| stock_quantity=20, | |
| description="Traditional Norwegian sourdough bread", | |
| ), | |
| Product( | |
| name="Premium Beef", | |
| vendor_id=3, | |
| sku="BEEF-PREM-001", | |
| price=199.99, | |
| stock_quantity=15, | |
| description="High quality beef from local cattle", | |
| ), | |
| Product( | |
| name="Fresh Carrots", | |
| vendor_id=1, | |
| sku="ORG-CAR-001", | |
| price=19.99, | |
| stock_quantity=80, | |
| description="Organic carrots grown locally", | |
| ), | |
| Product( | |
| name="Rye Bread", | |
| vendor_id=2, | |
| sku="BREAD-RYE-001", | |
| price=39.00, | |
| stock_quantity=25, | |
| description="Dark Norwegian rye bread", | |
| ), | |
| ] | |
| for product in sample_products: | |
| products_table.insert(product) | |
| print("Added sample products") | |
| # 5. UI Helper Functions | |
| def get_input_for_field(field, current_value=None): | |
| """Returns a fasthtml input component based on a dataclass field's type.""" | |
| ft, fn = field.type, field.name | |
| attrs = {"name": fn} | |
| if ft is bool: | |
| attrs["type"] = "checkbox" | |
| attrs["value"] = "true" | |
| if current_value: | |
| attrs["checked"] = True | |
| return Group( | |
| Input(type="hidden", name=fn, value="false"), | |
| Input(**attrs), | |
| ) | |
| if ft is Text: | |
| return Textarea(str(current_value or ""), **attrs) | |
| if current_value is not None: | |
| attrs["value"] = str(current_value) | |
| if ft is str: | |
| attrs["type"] = "text" | |
| elif ft is int: | |
| attrs.update({"type": "number", "step": "1"}) | |
| elif ft is float: | |
| attrs.update({"type": "number", "step": "any"}) | |
| elif ft is date: | |
| attrs["type"] = "date" | |
| else: | |
| attrs["type"] = "text" # Fallback | |
| return Input(**attrs) | |
| def render_cell(model_name, item, field): | |
| """Renders a single table cell with HTMX attributes for click-to-edit.""" | |
| # Convert dict to dataclass if needed | |
| if isinstance(item, dict): | |
| item_id = item.get("id") | |
| field_value = item.get(field.name) | |
| else: | |
| item_id = item.id | |
| field_value = getattr(item, field.name) | |
| field_name = field.name | |
| cell_id = f"cell-{item_id}-{field_name}" | |
| return Td( | |
| field_value, | |
| id=cell_id, | |
| hx_get=f"/{model_name}/edit_cell/{item_id}/{field_name}", | |
| hx_target=f"#{cell_id}", | |
| hx_swap="outerHTML", | |
| ) | |
| def render_table(model_name, model_cls, items): | |
| """Renders a full data table for a given model and its items.""" | |
| pk_name = next((f.name for f in fields(model_cls) if f.metadata.get("pk")), "id") | |
| data_fields = [f for f in fields(model_cls) if not f.metadata.get("pk")] | |
| headers = [pk_name] + [f.name for f in data_fields] + ["Actions"] | |
| def render_row(item): | |
| # Convert dict to dataclass if needed | |
| if isinstance(item, dict): | |
| item = model_cls(**item) | |
| item_id = getattr(item, pk_name) | |
| return Tr( | |
| Td(item_id), | |
| *[render_cell(model_name, item, f) for f in data_fields], | |
| Td( | |
| Group( | |
| A( | |
| "Edit", | |
| href=f"/{model_name}/edit/{item_id}", | |
| role="button", | |
| cls="outline", | |
| ), | |
| Button( | |
| "Delete", | |
| hx_post=f"/{model_name}/delete/{item_id}", | |
| hx_target=f"#row-{item_id}", | |
| hx_swap="outerHTML", | |
| hx_confirm=f"Delete {model_cls.__name__}?", | |
| cls="outline secondary", | |
| ), | |
| ) | |
| ), | |
| id=f"row-{item_id}", | |
| ) | |
| return Table( | |
| Thead(Tr(*[Th(header) for header in headers])), Tbody(*map(render_row, items)) | |
| ) | |
| def render_form(model_name, model_cls, instance=None): | |
| """Renders a create/edit form for a given model.""" | |
| pk_name = next((f.name for f in fields(model_cls) if f.metadata.get("pk")), "id") | |
| action_url = ( | |
| f"/{model_name}/create" | |
| if instance is None | |
| else f"/{model_name}/update/{getattr(instance, pk_name)}" | |
| ) | |
| form_fields = [ | |
| Label( | |
| f.name.replace("_", " ").title(), | |
| get_input_for_field(f, getattr(instance, f.name, None)), | |
| ) | |
| for f in fields(model_cls) | |
| if not f.metadata.get("pk") | |
| ] | |
| return Form( | |
| *form_fields, Button("Save", type="submit"), action=action_url, method="post" | |
| ) | |
| async def process_and_save(req, model_cls, db_table, item_id=None): | |
| """Helper to process form data and save to the database.""" | |
| form_data = await req.form() | |
| data_dict = {} | |
| for f in fields(model_cls): | |
| if f.name in form_data: | |
| val = form_data.get(f.name) | |
| if f.type is bool: | |
| data_dict[f.name] = val == "true" | |
| elif f.type is int: | |
| data_dict[f.name] = int(val) if val else 0 | |
| elif f.type is float: | |
| data_dict[f.name] = float(val) if val else 0.0 | |
| elif f.type is date and val: | |
| data_dict[f.name] = date.fromisoformat(val) | |
| elif f.type is not date: | |
| data_dict[f.name] = val | |
| if item_id: # Update | |
| pk_name = next( | |
| (f.name for f in fields(model_cls) if f.metadata.get("pk")), "id" | |
| ) | |
| data_dict[pk_name] = item_id | |
| db_table.update(model_cls(**data_dict)) | |
| else: # Create | |
| db_table.insert(model_cls(**data_dict)) | |
| # 6. Route Handlers | |
| @rt("/") | |
| def get_dashboard(): | |
| if not MODEL_REGISTRY: | |
| setup_database_and_models() | |
| cards = [] | |
| for model_name in MODEL_REGISTRY.keys(): | |
| model_cls, db_table = MODEL_REGISTRY[model_name] | |
| count = len(db_table()) | |
| cards.append( | |
| Card( | |
| Header(H3(model_name.title())), | |
| P(f"{count} records"), | |
| Footer( | |
| Group( | |
| A( | |
| f"Manage {model_name.title()}", | |
| href=f"/{model_name}/list", | |
| role="button", | |
| cls="outline", | |
| ), | |
| A( | |
| f"New {model_cls.__name__}", | |
| href=f"/{model_name}/new", | |
| role="button", | |
| ), | |
| ) | |
| ), | |
| ) | |
| ) | |
| return Titled( | |
| "Admin Dashboard", Main(H1("Data Management"), Grid(*cards), cls="container") | |
| ) | |
| @rt("/{model_name}/list") | |
| def get_list(model_name: str): | |
| if not MODEL_REGISTRY: | |
| setup_database_and_models() | |
| if model_name not in MODEL_REGISTRY: | |
| return "Model not found", 404 | |
| model_cls, db_table = MODEL_REGISTRY[model_name] | |
| items = db_table() | |
| return Titled( | |
| f"Manage {model_name.title()}", | |
| Main( | |
| H1(f"All {model_name.title()}"), | |
| P( | |
| Group( | |
| A("← Dashboard", href="/", cls="outline"), | |
| A( | |
| f"New {model_cls.__name__}", | |
| href=f"/{model_name}/new", | |
| role="button", | |
| ), | |
| ) | |
| ), | |
| render_table(model_name, model_cls, items) | |
| if items | |
| else P( | |
| f"No {model_name} found. ", | |
| A(f"Create the first {model_cls.__name__}", href=f"/{model_name}/new"), | |
| ), | |
| cls="container", | |
| ), | |
| ) | |
| @rt("/{model_name}/new") | |
| def get_new(model_name: str): | |
| if not MODEL_REGISTRY or model_name not in MODEL_REGISTRY: | |
| return "Model not found", 404 | |
| model_cls, _ = MODEL_REGISTRY[model_name] | |
| return Titled( | |
| f"New {model_cls.__name__}", | |
| Main( | |
| H1(f"New {model_cls.__name__}"), | |
| P( | |
| A( | |
| f"← Back to {model_name.title()}", | |
| href=f"/{model_name}/list", | |
| cls="outline", | |
| ) | |
| ), | |
| render_form(model_name, model_cls), | |
| cls="container", | |
| ), | |
| ) | |
| @rt("/{model_name}/edit/{item_id:int}") | |
| def get_edit(model_name: str, item_id: int): | |
| if not MODEL_REGISTRY or model_name not in MODEL_REGISTRY: | |
| return "Model not found", 404 | |
| model_cls, db_table = MODEL_REGISTRY[model_name] | |
| item_data = db_table[item_id] | |
| # Convert dict to dataclass if needed | |
| if isinstance(item_data, dict): | |
| item = model_cls(**item_data) | |
| else: | |
| item = item_data | |
| return Titled( | |
| f"Edit {model_cls.__name__}", | |
| Main( | |
| H1(f"Editing {model_cls.__name__} #{item_id}"), | |
| P( | |
| A( | |
| f"← Back to {model_name.title()}", | |
| href=f"/{model_name}/list", | |
| cls="outline", | |
| ) | |
| ), | |
| render_form(model_name, model_cls, item), | |
| cls="container", | |
| ), | |
| ) | |
| @rt("/{model_name}/create") | |
| async def post_create(model_name: str, req: Request): | |
| model_cls, db_table = MODEL_REGISTRY[model_name] | |
| await process_and_save(req, model_cls, db_table) | |
| return RedirectResponse(f"/{model_name}/list", status_code=303) | |
| @rt("/{model_name}/update/{item_id:int}") | |
| async def post_update(model_name: str, item_id: int, req: Request): | |
| model_cls, db_table = MODEL_REGISTRY[model_name] | |
| await process_and_save(req, model_cls, db_table, item_id) | |
| return RedirectResponse(f"/{model_name}/list", status_code=303) | |
| @rt("/{model_name}/delete/{item_id:int}") | |
| def post_delete(model_name: str, item_id: int): | |
| _, db_table = MODEL_REGISTRY[model_name] | |
| db_table.delete(item_id) | |
| return "" | |
| @rt("/{model_name}/edit_cell/{item_id:int}/{field_name}") | |
| def get_edit_cell(model_name: str, item_id: int, field_name: str): | |
| model_cls, db_table = MODEL_REGISTRY[model_name] | |
| item_data = db_table[item_id] | |
| # Convert dict to dataclass if needed | |
| if isinstance(item_data, dict): | |
| item = model_cls(**item_data) | |
| field_value = item_data.get(field_name) | |
| else: | |
| item = item_data | |
| field_value = getattr(item, field_name) | |
| field = next(f for f in fields(model_cls) if f.name == field_name) | |
| input_el = get_input_for_field(field, field_value) | |
| if not isinstance(input_el, (tuple, list)): | |
| input_el.attrs.update( | |
| { | |
| "hx_post": f"/{model_name}/update_cell/{item_id}/{field_name}", | |
| "hx_target": f"#cell-{item_id}-{field_name}", | |
| "hx_swap": "outerHTML", | |
| "hx_trigger": "blur, keyup[key=='Enter']", | |
| "autofocus": "true", | |
| } | |
| ) | |
| return input_el | |
| @rt("/{model_name}/update_cell/{item_id:int}/{field_name}") | |
| async def post_update_cell( | |
| model_name: str, item_id: int, field_name: str, req: Request | |
| ): | |
| model_cls, db_table = MODEL_REGISTRY[model_name] | |
| item_data = db_table[item_id] | |
| # Convert dict to dataclass if needed | |
| if isinstance(item_data, dict): | |
| item = model_cls(**item_data) | |
| else: | |
| item = item_data | |
| form_data = await req.form() | |
| new_value_str = form_data.get(field_name) | |
| field = next(f for f in fields(model_cls) if f.name == field_name) | |
| # Type coercion | |
| if field.type is bool: | |
| typed_value = new_value_str == "true" | |
| elif field.type is int: | |
| typed_value = int(new_value_str) | |
| elif field.type is float: | |
| typed_value = float(new_value_str) | |
| elif field.type is date: | |
| typed_value = date.fromisoformat(new_value_str) | |
| else: | |
| typed_value = new_value_str | |
| setattr(item, field_name, typed_value) | |
| db_table.update(item) | |
| # Get updated item for rendering | |
| updated_item = db_table[item_id] | |
| if isinstance(updated_item, dict): | |
| updated_item = model_cls(**updated_item) | |
| return render_cell(model_name, updated_item, field) | |
| # 7. Application Startup | |
| def initialize_app(): | |
| """Initialize the application with database setup.""" | |
| setup_database_and_models() | |
| # Initialize when module is imported | |
| initialize_app() | |
| if __name__ == "__main__": | |
| serve(port=9000) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment