Skip to content

Commit defdc91

Browse files
committed
Bump version to 0.1.2 and enhance authentication to support maintenance mode with admin checks
1 parent 63a1ca4 commit defdc91

5 files changed

Lines changed: 466 additions & 42 deletions

File tree

README.md

Lines changed: 173 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,11 @@ A Python library for user authentication, admin management, and database adminis
44

55
- User management (registration, authentication, and confirmation)
66
- Admin user creation and management
7-
- Database backup and restore
8-
- Works with both FastHTML database and dictionary stores
9-
- FastHTML best practices for session handling and authentication
7+
- Sqlite database download, upload, backup and restore
108
- Built-in validation functions for passwords, emails, and more
119
- Extensible validation system for custom validation rules
1210
- HTMX integration for real-time form validation
13-
- OAuth integration for third-party authentication
11+
- OAuth integration
1412

1513
## Installation
1614

@@ -108,6 +106,12 @@ print(f"Database backed up to: {backup_path}")
108106
# Restore the database from a backup
109107
admin_manager.restore_database("data/myapp.db", backup_path)
110108
print("Database restored successfully")
109+
110+
# Upload a database file
111+
with open("path/to/uploaded_file.db", "rb") as f:
112+
file_content = f.read()
113+
admin_manager.upload_database("data/myapp.db", file_content)
114+
print("Database uploaded successfully")
111115
```
112116

113117
### Email Confirmation
@@ -762,6 +766,171 @@ It works with any OAuth provider supported by FastHTML, including:
762766

763767
For a complete example, see `example_oauth.py`.
764768

769+
## Maintenance Mode
770+
771+
The library includes a persistent maintenance mode feature that allows administrators to temporarily restrict access to the system for all non-admin users. When maintenance mode is enabled, non-admin users (including anonymous users) are redirected to a maintenance page.
772+
773+
```python
774+
from fasthtml.common import *
775+
from fasthtml_admin import UserManager, AdminManager, auth_before
776+
777+
# Initialize UserManager and AdminManager
778+
db = database("data/myapp.db")
779+
user_manager = UserManager(db)
780+
admin_manager = AdminManager(user_manager)
781+
782+
# Set up authentication with maintenance mode support
783+
def app_auth_before(req, sess):
784+
return auth_before(req, sess, user_manager,
785+
login_url='/login',
786+
public_paths=['/', '/register'],
787+
admin_manager=admin_manager,
788+
maintenance_url='/maintenance')
789+
790+
# Create a FastHTML app with authentication
791+
beforeware = Beforeware(app_auth_before)
792+
app, rt = fast_app(
793+
secret_key="your-secret-key-here",
794+
before=beforeware,
795+
session_cookie="session"
796+
)
797+
798+
# Add a maintenance page
799+
@app.get("/maintenance")
800+
def maintenance_page():
801+
return Container(
802+
H1("System Maintenance"),
803+
P("The system is currently undergoing maintenance."),
804+
P("Please check back later."),
805+
P("If you are an administrator, please log in to access the system."),
806+
A("Login", href="/login", cls="button")
807+
)
808+
809+
# Add controls for admins to toggle maintenance mode
810+
@app.post("/admin/maintenance-mode")
811+
def toggle_maintenance_mode(enabled: str, session):
812+
user = get_current_user(session, user_manager)
813+
is_admin = user.is_admin if user_manager.is_db else user["is_admin"]
814+
815+
if not is_admin:
816+
return RedirectResponse("/dashboard", status_code=303)
817+
818+
# Convert string to boolean
819+
enable_mode = enabled.lower() == "true"
820+
821+
# Set maintenance mode
822+
admin_manager.set_maintenance_mode(enable_mode)
823+
824+
return RedirectResponse("/admin", status_code=303)
825+
```
826+
827+
This example demonstrates:
828+
1. Setting up the AdminManager with maintenance mode support
829+
2. Configuring the auth_before function to check for maintenance mode
830+
3. Adding a maintenance page that users will be redirected to
831+
4. Adding controls for admins to toggle maintenance mode on/off
832+
833+
When maintenance mode is enabled:
834+
- Admin users can still access all parts of the system
835+
- Non-admin users (including anonymous users) are redirected to the maintenance page when they try to access any part of the system
836+
- The login page remains accessible so admins can log in
837+
- The maintenance page is always accessible
838+
839+
### Persistent Maintenance Mode
840+
841+
The maintenance mode state is stored in the database, making it persistent across application restarts. This means that if you enable maintenance mode and restart your application, it will remain in maintenance mode.
842+
843+
```python
844+
# Initialize AdminManager
845+
admin_manager = AdminManager(user_manager)
846+
847+
# Check current maintenance mode status
848+
is_maintenance = admin_manager.is_maintenance_mode()
849+
print(f"Maintenance mode is {'enabled' if is_maintenance else 'disabled'}")
850+
851+
# Enable maintenance mode
852+
admin_manager.set_maintenance_mode(True)
853+
854+
# Disable maintenance mode
855+
admin_manager.set_maintenance_mode(False)
856+
```
857+
858+
The maintenance mode state is stored in a system_settings table in the database, which is automatically created when you initialize the AdminManager. This ensures that the maintenance mode state is preserved even if the application is restarted.
859+
860+
## Database Upload Example
861+
862+
Here's an example of how to implement a database upload feature in your FastHTML application:
863+
864+
```python
865+
from fasthtml.common import limiter, RedirectResponse, Container, H1, P, A, Form, Input, Button, Titled
866+
867+
@app.get("/admin/upload-db")
868+
def get_upload_db(session):
869+
user = get_current_user(session, user_manager)
870+
# Check if user is admin
871+
is_admin = user.is_admin if user_manager.is_db else user["is_admin"]
872+
if not is_admin:
873+
return Container(
874+
H1("Access Denied"),
875+
P("You do not have permission to access this page."),
876+
A("Go to Dashboard", href="/dashboard", cls="button")
877+
)
878+
879+
# Create upload form
880+
form = Form(
881+
H1("Upload Database"),
882+
P("Warning: This will replace the current database with the uploaded file."),
883+
Input(name="dbfile", type="file", accept=".db", required=True),
884+
Button("Upload", type="submit"),
885+
A("Cancel", href="/admin", cls="button secondary"),
886+
action="/admin/upload-db",
887+
method="post",
888+
enctype="multipart/form-data"
889+
)
890+
891+
return Container(form)
892+
893+
@limiter.limit("30/day") # Rate limit to prevent abuse
894+
@app.post("/admin/upload-db")
895+
async def post_upload_db(request, session):
896+
user = get_current_user(session, user_manager)
897+
# Check if user is admin
898+
is_admin = user.is_admin if user_manager.is_db else user["is_admin"]
899+
if not is_admin:
900+
return Container(
901+
H1("Access Denied"),
902+
P("You do not have permission to access this page."),
903+
A("Go to Dashboard", href="/dashboard", cls="button")
904+
)
905+
906+
try:
907+
# Process form data
908+
form = await request.form()
909+
file = form["dbfile"]
910+
if not file.filename.endswith('.db'):
911+
return Titled("Error", P("Invalid file type. Please upload a .db file."))
912+
913+
# Use AdminManager to upload database
914+
file_content = await file.read()
915+
admin_manager.upload_database("data/myapp.db", file_content)
916+
917+
return RedirectResponse("/admin?success=true", status_code=303)
918+
919+
except ValueError as e:
920+
return Titled("Error", P(str(e)))
921+
except Exception as e:
922+
return Titled("Error", P(f"Failed to process upload request: {str(e)}"))
923+
```
924+
925+
This example demonstrates:
926+
1. A GET route to display the upload form
927+
2. A POST route to handle the file upload
928+
3. Rate limiting to prevent abuse
929+
4. Admin permission checks
930+
5. File validation (must be a .db file)
931+
6. Using the AdminManager's upload_database method to handle the upload
932+
7. Error handling for different types of errors
933+
765934
## Customization
766935

767936
The library is designed to be flexible and customizable. You can extend the provided classes or implement your own versions of the interfaces to fit your specific needs.

example.py

Lines changed: 82 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ def validate_username(username: str) -> tuple[bool, str]:
8383
# Initialize AdminManager with our UserManager
8484
admin_manager = AdminManager(user_manager)
8585

86+
# You can enable maintenance mode by default if needed
87+
# admin_manager.set_maintenance_mode(True)
88+
8689
# Create an admin user if environment variables are provided
8790
admin_email = os.environ.get("ADMIN_EMAIL", "admin@example.com")
8891
admin_password = os.environ.get("ADMIN_PASSWORD", "adminpass")
@@ -92,7 +95,9 @@ def validate_username(username: str) -> tuple[bool, str]:
9295
def app_auth_before(req, sess):
9396
return auth_before(req, sess, user_manager,
9497
login_url='/login',
95-
public_paths=['/', '/login', '/register', '/advanced-register', '/confirm-email', '/confirm-email/'])
98+
public_paths=['/', '/login', '/register', '/advanced-register', '/confirm-email', '/confirm-email/'],
99+
admin_manager=admin_manager,
100+
maintenance_url='/maintenance')
96101

97102
# Fake email sending function
98103
def send_confirmation_email(email, token):
@@ -622,10 +627,30 @@ def admin_panel(session):
622627
A("Go to Dashboard", href="/dashboard", cls="button")
623628
)
624629

630+
# Get maintenance mode status
631+
maintenance_mode = admin_manager.is_maintenance_mode()
632+
625633
return Container(
626634
H1("Admin Panel"),
627635
P("Welcome to the admin panel!"),
628636
P("This is a protected page that only admin users can access."),
637+
638+
H2("System Management"),
639+
Div(
640+
Form(
641+
Input(type="hidden", name="enabled", value=str(not maintenance_mode).lower()),
642+
Button(
643+
"Disable Maintenance Mode" if maintenance_mode else "Enable Maintenance Mode",
644+
type="submit",
645+
cls="button " + ("primary" if not maintenance_mode else "secondary")
646+
),
647+
action="/admin/maintenance-mode",
648+
method="post"
649+
),
650+
P(f"Maintenance Mode is currently {'enabled' if maintenance_mode else 'disabled'}."),
651+
style="margin-bottom: 2rem;"
652+
),
653+
629654
H2("Database Management"),
630655
Div(
631656
A("Backup Database", href="/admin/backup-db", cls="button"),
@@ -637,6 +662,41 @@ def admin_panel(session):
637662
A("Logout", href="/logout", cls="button secondary")
638663
)
639664

665+
@app.post("/admin/maintenance-mode")
666+
def toggle_maintenance_mode(enabled: str, session):
667+
user = get_current_user(session, user_manager)
668+
# The auth_before Beforeware will handle redirecting if not logged in
669+
670+
is_admin = user.is_admin if user_manager.is_db else user["is_admin"]
671+
if not is_admin:
672+
return Container(
673+
H1("Access Denied"),
674+
P("You do not have permission to access this page."),
675+
A("Go to Dashboard", href="/dashboard", cls="button")
676+
)
677+
678+
# Convert string to boolean
679+
enable_mode = enabled.lower() == "true"
680+
681+
# Set maintenance mode
682+
admin_manager.set_maintenance_mode(enable_mode)
683+
684+
return RedirectResponse("/admin", status_code=303)
685+
686+
@app.get("/maintenance")
687+
def maintenance_page():
688+
"""
689+
Maintenance page shown to non-admin users when maintenance mode is enabled.
690+
"""
691+
return Container(
692+
H1("System Maintenance"),
693+
P("The system is currently undergoing maintenance."),
694+
P("Please check back later."),
695+
P("If you are an administrator, please log in to access the system."),
696+
A("Login", href="/login", cls="button"),
697+
style="text-align: center; max-width: 600px; margin: 0 auto; padding: 2rem;"
698+
)
699+
640700
@app.get("/admin/backup-db")
641701
def backup_db(session):
642702
user = get_current_user(session, user_manager)
@@ -720,7 +780,7 @@ def get_upload_db(session):
720780
form = Form(
721781
H1("Upload Database"),
722782
P("Warning: This will replace the current database with the uploaded file."),
723-
Input(name="db_file", type="file", accept=".db,.bak", required=True),
783+
Input(name="dbfile", type="file", accept=".db", required=True),
724784
Button("Upload", type="submit"),
725785
A("Cancel", href="/admin", cls="button secondary"),
726786
action="/admin/upload-db",
@@ -731,7 +791,7 @@ def get_upload_db(session):
731791
return Container(form)
732792

733793
@app.post("/admin/upload-db")
734-
async def post_upload_db(req, session):
794+
async def post_upload_db(request, session):
735795
user = get_current_user(session, user_manager)
736796
# The auth_before Beforeware will handle redirecting if not logged in
737797

@@ -744,38 +804,22 @@ async def post_upload_db(req, session):
744804
)
745805

746806
try:
747-
form = await req.form()
748-
db_file = form.get("db_file")
749-
750-
if not db_file:
751-
return Container(
752-
H1("Upload Failed"),
753-
P("No file selected."),
754-
A("Try Again", href="/admin/upload-db", cls="button")
755-
)
756-
757-
# Save uploaded file to temporary location
758-
temp_path = os.path.join(db_path, "temp_upload.db")
759-
with open(temp_path, "wb") as f:
760-
f.write(await db_file.read())
807+
# Process form data
808+
form = await request.form()
809+
file = form["dbfile"]
810+
if not file.filename.endswith('.db'):
811+
return Titled("Error", P("Invalid file type. Please upload a .db file."))
761812

762-
# Restore database from temporary file
763-
admin_manager.restore_database(os.path.join(db_path, "example.db"), temp_path)
813+
# Use AdminManager to upload database
814+
file_content = await file.read()
815+
admin_manager.upload_database(os.path.join(db_path, "example.db"), file_content)
764816

765-
# Remove temporary file
766-
os.remove(temp_path)
767-
768-
return Container(
769-
H1("Database Upload"),
770-
P("Database uploaded and restored successfully."),
771-
A("Go to Admin Panel", href="/admin", cls="button")
772-
)
817+
return RedirectResponse("/admin?success=true", status_code=303)
818+
819+
except ValueError as e:
820+
return Titled("Error", P(str(e)))
773821
except Exception as e:
774-
return Container(
775-
H1("Upload Failed"),
776-
P(f"Error: {str(e)}"),
777-
A("Try Again", href="/admin/upload-db", cls="button")
778-
)
822+
return Titled("Error", P(f"Failed to process upload request: {str(e)}"))
779823

780824
@app.get("/edit-profile")
781825
def get_edit_profile(session):
@@ -877,4 +921,9 @@ def post_edit_profile(first_name: str = "", last_name: str = "", phone: str = ""
877921
A("Try Again", href="/edit-profile", cls="button")
878922
)
879923

880-
serve(host="localhost", port=8000)
924+
925+
if __name__ == "__main__":
926+
print("\nStarting example server...")
927+
print("Admin user created with email:", admin_email)
928+
print("Admin user password:", admin_password)
929+
serve(host="localhost", port=8000)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fasthtml-admin"
3-
version = "0.1.1"
3+
version = "0.1.2"
44
description = "Providing helper user management and authentication functions for FastHTML"
55
readme = "README.md"
66
requires-python = ">=3.12"

0 commit comments

Comments
 (0)