fastapi-lite-admin 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ from .main import Admin
2
+
3
+ __all__ = ["Admin"]
@@ -0,0 +1,14 @@
1
+ from typing import List, Optional
2
+ from pydantic import BaseModel, Field
3
+
4
+ class ModelConfig(BaseModel):
5
+ name: str
6
+ display_name: Optional[str] = None
7
+ fields: List[str] = Field(default_factory=list)
8
+ readonly_fields: List[str] = Field(default_factory=list)
9
+ searchable_fields: List[str] = Field(default_factory=list)
10
+ hidden_fields: List[str] = Field(default_factory=list)
11
+
12
+ @property
13
+ def label(self) -> str:
14
+ return self.display_name or self.name.capitalize()
@@ -0,0 +1,115 @@
1
+ from typing import Any, List, Optional, Type
2
+ from sqlalchemy.orm import Session
3
+ from sqlalchemy import Boolean, Integer, Float
4
+
5
+ class CRUDEngine:
6
+ def __init__(self, model: Type[Any]):
7
+ self.model = model
8
+
9
+ def _prepare_data(self, data: dict) -> dict:
10
+ """
11
+ Cast string values from form data to correct types based on SQLAlchemy model.
12
+ """
13
+ prepared = {}
14
+ columns = self.model.__table__.columns
15
+
16
+ for key, value in data.items():
17
+ if key not in columns:
18
+ continue
19
+
20
+ col = columns[key]
21
+
22
+ if isinstance(col.type, Boolean):
23
+ if isinstance(value, str):
24
+ prepared[key] = value.lower() in ('true', '1', 'yes', 'on')
25
+ else:
26
+ prepared[key] = bool(value)
27
+ elif isinstance(col.type, Integer):
28
+ try:
29
+ prepared[key] = int(value)
30
+ except (ValueError, TypeError):
31
+ prepared[key] = value
32
+ elif isinstance(col.type, Float):
33
+ try:
34
+ prepared[key] = float(value)
35
+ except (ValueError, TypeError):
36
+ prepared[key] = value
37
+ else:
38
+ prepared[key] = value
39
+
40
+ return prepared
41
+
42
+ def count(self, db: Session, search: Optional[str] = None) -> int:
43
+ query = db.query(self.model)
44
+ # TODO: Implement search filtering in count
45
+ return query.count()
46
+
47
+ def list(
48
+ self,
49
+ db: Session,
50
+ skip: int = 0,
51
+ limit: int = 100,
52
+ search: Optional[str] = None,
53
+ order_by: Optional[str] = None,
54
+ order_dir: str = "asc"
55
+ ) -> List[Any]:
56
+ query = db.query(self.model)
57
+
58
+ # Apply Search
59
+ if search:
60
+ # TODO: Implement generic search logic
61
+ pass
62
+
63
+ # Apply Sorting
64
+ if order_by and hasattr(self.model, order_by):
65
+ column = getattr(self.model, order_by)
66
+ if order_dir.lower() == "desc":
67
+ query = query.order_by(column.desc())
68
+ else:
69
+ query = query.order_by(column.asc())
70
+
71
+ return query.offset(skip).limit(limit).all()
72
+
73
+ def _cast_id(self, id: Any) -> Any:
74
+ """
75
+ Cast ID to the correct type based on the model's primary key.
76
+ """
77
+ col = self.model.__table__.primary_key.columns[0]
78
+ if isinstance(col.type, Integer):
79
+ try:
80
+ return int(id)
81
+ except (ValueError, TypeError):
82
+ return id
83
+ return id
84
+
85
+ def get(self, db: Session, id: Any) -> Optional[Any]:
86
+ casted_id = self._cast_id(id)
87
+ return db.query(self.model).filter(self.model.id == casted_id).first()
88
+
89
+ def create(self, db: Session, data: dict) -> Any:
90
+ prepared_data = self._prepare_data(data)
91
+ obj = self.model(**prepared_data)
92
+ db.add(obj)
93
+ db.commit()
94
+ db.refresh(obj)
95
+ return obj
96
+
97
+ def update(self, db: Session, id: Any, data: dict) -> Optional[Any]:
98
+ obj = self.get(db, id)
99
+ if not obj:
100
+ return None
101
+
102
+ prepared_data = self._prepare_data(data)
103
+ for key, value in prepared_data.items():
104
+ setattr(obj, key, value)
105
+ db.commit()
106
+ db.refresh(obj)
107
+ return obj
108
+
109
+ def delete(self, db: Session, id: Any) -> bool:
110
+ obj = self.get(db, id)
111
+ if not obj:
112
+ return False
113
+ db.delete(obj)
114
+ db.commit()
115
+ return True
@@ -0,0 +1,39 @@
1
+ from typing import Any, Dict, Type, Callable, Optional
2
+ from dataclasses import dataclass, field
3
+
4
+ @dataclass
5
+ class ModelRegistration:
6
+ model: Type[Any]
7
+ get_db: Callable
8
+ config: Dict[str, Any] = field(default_factory=dict)
9
+ name: str = ""
10
+
11
+ class Registry:
12
+ def __init__(self):
13
+ self._models: Dict[str, ModelRegistration] = {}
14
+
15
+ def register(
16
+ self,
17
+ model: Type[Any],
18
+ get_db: Callable,
19
+ config: Optional[Dict[str, Any]] = None
20
+ ):
21
+ config = config or {}
22
+ name = config.get("name") or model.__name__.lower()
23
+
24
+ if name in self._models:
25
+ raise ValueError(f"Model with name '{name}' already registered.")
26
+
27
+ registration = ModelRegistration(
28
+ model=model,
29
+ get_db=get_db,
30
+ config=config,
31
+ name=name
32
+ )
33
+ self._models[name] = registration
34
+
35
+ def get_models(self) -> Dict[str, ModelRegistration]:
36
+ return self._models
37
+
38
+ def get_model(self, name: str) -> Optional[ModelRegistration]:
39
+ return self._models.get(name)
@@ -0,0 +1,27 @@
1
+ from typing import Any, Dict, List
2
+ from .registry import Registry
3
+ from ..integrations.sqlalchemy import introspect_sqlalchemy_model
4
+
5
+ def generate_admin_schema(registry: Registry) -> Dict[str, Any]:
6
+ """
7
+ Generate a JSON schema of all registered models for the UI.
8
+ """
9
+ models_info = []
10
+ for name, reg in registry.get_models().items():
11
+ introspection = introspect_sqlalchemy_model(reg.model)
12
+
13
+ clean_config = reg.config.copy()
14
+ if "attention_filter" in clean_config:
15
+ del clean_config["attention_filter"]
16
+
17
+ models_info.append({
18
+ "name": name,
19
+ "display_name": reg.config.get("display_name") or name.capitalize(),
20
+ "fields": introspection["fields"],
21
+ "list_display": reg.config.get("list_display"),
22
+ "config": clean_config
23
+ })
24
+
25
+ return {
26
+ "models": models_info
27
+ }
@@ -0,0 +1,7 @@
1
+ # Common database dependencies
2
+ from typing import Generator
3
+ from sqlalchemy.orm import Session
4
+
5
+ # This can be used as a default if none is provided, but usually the user provides their own get_db
6
+ def get_db_placeholder() -> Generator[Session, None, None]:
7
+ yield None
@@ -0,0 +1,22 @@
1
+ from typing import Any, Dict, List, Type
2
+ from sqlalchemy import inspect
3
+ from sqlalchemy.orm import DeclarativeMeta
4
+
5
+ def introspect_sqlalchemy_model(model: Type[Any]) -> Dict[str, Any]:
6
+ """
7
+ Introspect a SQLAlchemy model to extract field information.
8
+ """
9
+ mapper = inspect(model)
10
+ fields = []
11
+ for column in mapper.columns:
12
+ fields.append({
13
+ "name": column.key,
14
+ "type": str(column.type),
15
+ "primary_key": column.primary_key,
16
+ "nullable": column.nullable,
17
+ "default": str(column.default) if column.default else None,
18
+ "required": not column.nullable and column.default is None and not column.primary_key
19
+ })
20
+ return {
21
+ "fields": fields
22
+ }
@@ -0,0 +1,103 @@
1
+ from typing import Any, Dict, List, Optional, Type, Callable
2
+ from fastapi import FastAPI, APIRouter, Depends
3
+ from .core.registry import Registry
4
+ from .routers.admin import create_admin_router
5
+ from .ui.views import create_ui_router
6
+
7
+ class Admin:
8
+ def __init__(
9
+ self,
10
+ title: str = "FastAPI Admin Lite",
11
+ base_url: str = "/admin",
12
+ enable_ui: bool = True,
13
+ dependencies: Optional[List[Any]] = None,
14
+ auth_dependency: Optional[Callable] = None,
15
+ permission_checker: Optional[Callable] = None,
16
+ dashboard_template: Optional[str] = None,
17
+ dashboard_models: Optional[List[str]] = None,
18
+ get_logs: Optional[Callable] = None,
19
+ logs_config: Optional[Dict[str, Any]] = None
20
+ ):
21
+ self.title = title
22
+ self.base_url = base_url
23
+ self.enable_ui = enable_ui
24
+ self.dependencies = dependencies or []
25
+ self.registry = Registry()
26
+ self.dashboard_template = dashboard_template
27
+ self.dashboard_models = dashboard_models
28
+ self.get_logs = get_logs
29
+ self.logs_config = logs_config or {
30
+ "columns": ["level", "timestamp", "message"],
31
+ "title": "System Activity"
32
+ }
33
+
34
+ # Auth & Permissions
35
+ self.auth_dependency = auth_dependency
36
+ self.permission_checker = permission_checker or self.default_permission
37
+
38
+ # Print warnings if not configured
39
+ if not auth_dependency:
40
+ print("\033[93m[WARNING] FastAPI Admin Lite: No auth_dependency provided. Admin panel is publicly accessible!\033[0m")
41
+ if not permission_checker:
42
+ print("\033[93m[WARNING] FastAPI Admin Lite: No permission_checker provided. Using default (allow all).\033[0m")
43
+
44
+ async def default_permission(self, user: Any = None) -> bool:
45
+ """Default permission checker that allows everything."""
46
+ return True
47
+
48
+ def register(
49
+ self,
50
+ model: Type[Any],
51
+ get_db: Callable,
52
+ list_display: Optional[List[str]] = None,
53
+ date_field: Optional[str] = None,
54
+ attention_filter: Optional[Any] = None,
55
+ readonly_fields: Optional[List[str]] = None,
56
+ config: Optional[Dict[str, Any]] = None
57
+ ):
58
+ """
59
+ Register a model with the admin panel.
60
+ """
61
+ config = config or {}
62
+ if list_display:
63
+ # Only include fields that exist in the model
64
+ valid_columns = {c.key for c in model.__table__.columns}
65
+ sanitized_display = [f for f in list_display if f in valid_columns]
66
+ config["list_display"] = sanitized_display
67
+
68
+ if date_field:
69
+ config["date_field"] = date_field
70
+
71
+ if attention_filter is not None:
72
+ config["attention_filter"] = attention_filter
73
+
74
+ if readonly_fields:
75
+ config["readonly_fields"] = readonly_fields
76
+
77
+ self.registry.register(model, get_db, config)
78
+
79
+ def mount(self, app: FastAPI):
80
+ """
81
+ Mount the admin router to the FastAPI application.
82
+ """
83
+ # Collect all dependencies
84
+ all_deps = list(self.dependencies)
85
+ if self.auth_dependency:
86
+ all_deps.append(Depends(self.auth_dependency))
87
+
88
+ router = create_admin_router(self)
89
+ app.include_router(
90
+ router,
91
+ prefix=self.base_url + "/api",
92
+ tags=["Admin"],
93
+ dependencies=all_deps
94
+ )
95
+
96
+ if self.enable_ui:
97
+ ui_router = create_ui_router(self)
98
+ app.include_router(
99
+ ui_router,
100
+ prefix=self.base_url,
101
+ include_in_schema=False,
102
+ dependencies=all_deps
103
+ )
@@ -0,0 +1,75 @@
1
+ from typing import Any, Dict, List
2
+ from fastapi import APIRouter, Depends, HTTPException, Query
3
+ from sqlalchemy.orm import Session
4
+ from ..core.schema import generate_admin_schema
5
+ from ..core.crud import CRUDEngine
6
+
7
+ def create_admin_router(admin: Any) -> APIRouter:
8
+ router = APIRouter()
9
+
10
+ @router.get("/schema")
11
+ async def get_schema():
12
+ return generate_admin_schema(admin.registry)
13
+
14
+ @router.get("/models")
15
+ async def list_models():
16
+ return list(admin.registry.get_models().keys())
17
+
18
+ # Dynamically generate routes for each registered model
19
+ for name, registration in admin.registry.get_models().items():
20
+ crud = CRUDEngine(registration.model)
21
+ get_db_dep = registration.get_db
22
+
23
+ def create_routes(model_name: str, crud_engine: CRUDEngine, db_dep: Any, reg: Any):
24
+ @router.get(f"/{model_name}", name=f"admin_list_{model_name}")
25
+ async def list_records(
26
+ skip: int = 0,
27
+ limit: int = 100,
28
+ search: str = None,
29
+ order_by: str = None,
30
+ order_dir: str = "asc",
31
+ db: Session = Depends(db_dep)
32
+ ):
33
+ records = crud_engine.list(
34
+ db, skip=skip, limit=limit, search=search,
35
+ order_by=order_by, order_dir=order_dir
36
+ )
37
+ total = crud_engine.count(db, search=search)
38
+ return {"data": records, "total": total}
39
+
40
+ @router.get(f"/{model_name}/{{id}}", name=f"admin_get_{model_name}")
41
+ async def get_record(id: Any, db: Session = Depends(db_dep)):
42
+ record = crud_engine.get(db, id)
43
+ if not record:
44
+ raise HTTPException(status_code=404, detail="Record not found")
45
+ return record
46
+
47
+ @router.post(f"/{model_name}", name=f"admin_create_{model_name}")
48
+ async def create_record(data: Dict[str, Any], db: Session = Depends(db_dep)):
49
+ readonly = reg.config.get("readonly_fields", [])
50
+ for field in readonly:
51
+ if field in data:
52
+ del data[field]
53
+ return crud_engine.create(db, data)
54
+
55
+ @router.put(f"/{model_name}/{{id}}", name=f"admin_update_{model_name}")
56
+ async def update_record(id: Any, data: Dict[str, Any], db: Session = Depends(db_dep)):
57
+ readonly = reg.config.get("readonly_fields", [])
58
+ for field in readonly:
59
+ if field in data:
60
+ del data[field]
61
+ record = crud_engine.update(db, id, data)
62
+ if not record:
63
+ raise HTTPException(status_code=404, detail="Record not found")
64
+ return record
65
+
66
+ @router.delete(f"/{model_name}/{{id}}", name=f"admin_delete_{model_name}")
67
+ async def delete_record(id: Any, db: Session = Depends(db_dep)):
68
+ success = crud_engine.delete(db, id)
69
+ if not success:
70
+ raise HTTPException(status_code=404, detail="Record not found")
71
+ return {"success": True}
72
+
73
+ create_routes(name, crud, get_db_dep, registration)
74
+
75
+ return router
@@ -0,0 +1,236 @@
1
+ {% extends "layout.html" %}
2
+
3
+ {% set active_nav = 'dashboard' %}
4
+
5
+ {% block extra_css %}
6
+ .stats-grid {
7
+ display: grid;
8
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
9
+ gap: 24px;
10
+ margin-bottom: 32px;
11
+ }
12
+
13
+ .stat-card {
14
+ padding: 24px;
15
+ position: relative;
16
+ }
17
+
18
+ .stat-card .icon {
19
+ position: absolute;
20
+ right: 24px;
21
+ top: 24px;
22
+ font-size: 1.5rem;
23
+ color: var(--primary);
24
+ opacity: 0.8;
25
+ }
26
+
27
+ .stat-card .label {
28
+ font-size: 0.8rem;
29
+ font-weight: 700;
30
+ text-transform: uppercase;
31
+ color: var(--text-muted);
32
+ letter-spacing: 0.05em;
33
+ margin-bottom: 8px;
34
+ }
35
+
36
+ .stat-card .value {
37
+ font-size: 2rem;
38
+ font-weight: 700;
39
+ margin-bottom: 8px;
40
+ }
41
+
42
+ .stat-card .trend {
43
+ font-size: 0.85rem;
44
+ font-weight: 500;
45
+ }
46
+
47
+ .trend.up { color: var(--success); }
48
+ .trend.down { color: var(--error); }
49
+ .trend.neutral { color: var(--text-muted); }
50
+
51
+ /* Recent Logs Table */
52
+ .section-header {
53
+ display: flex;
54
+ justify-content: space-between;
55
+ align-items: center;
56
+ margin-bottom: 16px;
57
+ }
58
+
59
+ .section-title {
60
+ font-size: 1.25rem;
61
+ font-weight: 700;
62
+ }
63
+
64
+ .view-all {
65
+ font-size: 0.85rem;
66
+ color: var(--primary);
67
+ text-decoration: none;
68
+ font-weight: 600;
69
+ }
70
+
71
+ .data-table {
72
+ width: 100%;
73
+ border-collapse: collapse;
74
+ }
75
+
76
+ .data-table th {
77
+ text-align: left;
78
+ padding: 12px 16px;
79
+ font-size: 0.75rem;
80
+ font-weight: 700;
81
+ text-transform: uppercase;
82
+ color: var(--text-muted);
83
+ border-bottom: 1px solid var(--border);
84
+ }
85
+
86
+ .data-table td {
87
+ padding: 16px;
88
+ font-size: 0.9rem;
89
+ border-bottom: 1px solid var(--border);
90
+ }
91
+
92
+ .badge {
93
+ padding: 4px 8px;
94
+ border-radius: 4px;
95
+ font-size: 0.7rem;
96
+ font-weight: 700;
97
+ text-transform: uppercase;
98
+ }
99
+
100
+ .badge-info { background-color: #e0f2fe; color: #0369a1; }
101
+ .badge-warn { background-color: #fef3c7; color: #b45309; }
102
+ .badge-error { background-color: #fee2e2; color: #b91c1c; }
103
+ .badge-success { background-color: #dcfce7; color: #15803d; }
104
+
105
+ .status-text {
106
+ font-weight: 600;
107
+ font-family: monospace;
108
+ }
109
+
110
+ .status-200 { color: var(--success); }
111
+ .status-422 { color: var(--error); }
112
+ .status-latency { color: var(--warning); }
113
+
114
+ /* Quick Actions */
115
+ .quick-actions {
116
+ margin-top: 32px;
117
+ display: grid;
118
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
119
+ gap: 24px;
120
+ }
121
+
122
+ .action-card {
123
+ display: flex;
124
+ gap: 20px;
125
+ padding: 24px;
126
+ cursor: pointer;
127
+ transition: transform 0.2s;
128
+ }
129
+
130
+ .action-card:hover {
131
+ transform: translateY(-2px);
132
+ }
133
+
134
+ .action-icon {
135
+ width: 48px;
136
+ height: 48px;
137
+ background-color: #f1f5f9;
138
+ border-radius: 12px;
139
+ display: flex;
140
+ align-items: center;
141
+ justify-content: center;
142
+ color: var(--text-muted);
143
+ font-size: 1.25rem;
144
+ }
145
+
146
+ .action-title {
147
+ font-weight: 700;
148
+ margin-bottom: 4px;
149
+ }
150
+
151
+ .action-desc {
152
+ font-size: 0.85rem;
153
+ color: var(--text-muted);
154
+ }
155
+ {% endblock %}
156
+
157
+ {% block content %}
158
+ <div class="page-header">
159
+ <h1 class="page-title">System Overview</h1>
160
+ <p class="page-subtitle">Real-time status of your FastAPI environment</p>
161
+ </div>
162
+
163
+ <div class="stats-grid">
164
+ {% for stat in stats %}
165
+ <div class="card stat-card" onclick="location.href='/admin/{{ stat.model_name }}'" style="cursor: pointer;">
166
+ <div class="label">{{ stat.name }}</div>
167
+ <div class="value">{{ stat.count }}</div>
168
+ <div class="trend {% if stat.has_date_field %}up{% else %}neutral{% endif %}">
169
+ <i class="fas {% if stat.has_date_field %}fa-clock{% else %}fa-exclamation-triangle{% endif %}"></i>
170
+ {% if stat.has_date_field %}
171
+ {{ stat.recent_count }} new in last 24h
172
+ {% else %}
173
+ Set date_field for 24h stats
174
+ {% endif %}
175
+ </div>
176
+ <div class="icon"><i class="fas fa-layer-group"></i></div>
177
+ </div>
178
+ {% endfor %}
179
+
180
+ {% if not stats %}
181
+ <div class="card stat-card">
182
+ <div class="label">System</div>
183
+ <div class="value">0</div>
184
+ <div class="trend">No models registered</div>
185
+ <div class="icon"><i class="fas fa-info-circle"></i></div>
186
+ </div>
187
+ {% endif %}
188
+ </div>
189
+
190
+ <div class="section-header">
191
+ <h2 class="section-title">{{ logs_config.title }}</h2>
192
+ {% if logs %}
193
+ <a href="#" class="view-all">View All Logs</a>
194
+ {% endif %}
195
+ </div>
196
+
197
+ <div class="card" style="padding: 0; overflow: hidden;">
198
+ <table class="data-table">
199
+ <thead>
200
+ <tr>
201
+ {% for col in logs_config.columns %}
202
+ <th>{{ col|capitalize }}</th>
203
+ {% endfor %}
204
+ </tr>
205
+ </thead>
206
+ <tbody>
207
+ {% for log in logs %}
208
+ <tr>
209
+ {% for col in logs_config.columns %}
210
+ <td>
211
+ {% if col == 'level' %}
212
+ {% set level = log[col]|upper %}
213
+ <span class="badge {% if level == 'INFO' %}badge-info{% elif level == 'ERROR' %}badge-error{% elif level == 'WARN' %}badge-warn{% else %}badge-success{% endif %}">
214
+ {{ level }}
215
+ </span>
216
+ {% else %}
217
+ {{ log[col] }}
218
+ {% endif %}
219
+ </td>
220
+ {% endfor %}
221
+ </tr>
222
+ {% endfor %}
223
+
224
+ {% if not logs %}
225
+ <tr>
226
+ <td colspan="100%" style="text-align: center; padding: 48px; color: var(--text-muted);">
227
+ <i class="fas fa-clipboard-list" style="font-size: 2rem; margin-bottom: 12px; display: block; opacity: 0.5;"></i>
228
+ No recent logs to display
229
+ </td>
230
+ </tr>
231
+ {% endif %}
232
+ </tbody>
233
+ </table>
234
+ </div>
235
+
236
+ {% endblock %}