splent-feature-admin 1.0.0__tar.gz
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.
- splent_feature_admin-1.0.0/MANIFEST.in +8 -0
- splent_feature_admin-1.0.0/PKG-INFO +5 -0
- splent_feature_admin-1.0.0/pyproject.toml +52 -0
- splent_feature_admin-1.0.0/setup.cfg +4 -0
- splent_feature_admin-1.0.0/src/splent_feature_admin.egg-info/PKG-INFO +5 -0
- splent_feature_admin-1.0.0/src/splent_feature_admin.egg-info/SOURCES.txt +19 -0
- splent_feature_admin-1.0.0/src/splent_feature_admin.egg-info/dependency_links.txt +1 -0
- splent_feature_admin-1.0.0/src/splent_feature_admin.egg-info/top_level.txt +1 -0
- splent_feature_admin-1.0.0/src/splent_io/splent_feature_admin/__init__.py +14 -0
- splent_feature_admin-1.0.0/src/splent_io/splent_feature_admin/config.py +10 -0
- splent_feature_admin-1.0.0/src/splent_io/splent_feature_admin/hooks.py +17 -0
- splent_feature_admin-1.0.0/src/splent_io/splent_feature_admin/migrations/alembic.ini +36 -0
- splent_feature_admin-1.0.0/src/splent_io/splent_feature_admin/migrations/env.py +9 -0
- splent_feature_admin-1.0.0/src/splent_io/splent_feature_admin/migrations/script.py.mako +24 -0
- splent_feature_admin-1.0.0/src/splent_io/splent_feature_admin/routes.py +121 -0
- splent_feature_admin-1.0.0/src/splent_io/splent_feature_admin/seeders.py +10 -0
- splent_feature_admin-1.0.0/src/splent_io/splent_feature_admin/services.py +119 -0
- splent_feature_admin-1.0.0/src/splent_io/splent_feature_admin/templates/admin/dashboard.html +49 -0
- splent_feature_admin-1.0.0/src/splent_io/splent_feature_admin/templates/admin/delete.html +50 -0
- splent_feature_admin-1.0.0/src/splent_io/splent_feature_admin/templates/admin/form.html +88 -0
- splent_feature_admin-1.0.0/src/splent_io/splent_feature_admin/templates/admin/list.html +101 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Compiled frontend bundles
|
|
2
|
+
recursive-include src/splent_io/splent_feature_admin/assets/dist *.js *.css *.map
|
|
3
|
+
|
|
4
|
+
# Jinja templates (feature views + hook fragments)
|
|
5
|
+
recursive-include src/splent_io/splent_feature_admin/templates *.html
|
|
6
|
+
|
|
7
|
+
# Alembic migration config and scripts
|
|
8
|
+
recursive-include src/splent_io/splent_feature_admin/migrations *.py *.ini *.mako
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=80.3.1", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "splent_feature_admin"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[tool.setuptools]
|
|
16
|
+
package-dir = { "" = "src" }
|
|
17
|
+
include-package-data = true
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["src"]
|
|
21
|
+
exclude = ["*.tests", "*.tests.*"]
|
|
22
|
+
|
|
23
|
+
[tool.splent]
|
|
24
|
+
cli_version = "1.4.5"
|
|
25
|
+
|
|
26
|
+
# ── Feature Contract (auto-generated) ────────────────────────────────────────
|
|
27
|
+
# Do not edit manually — re-run `splent feature:contract --write` to refresh.
|
|
28
|
+
[tool.splent.contract]
|
|
29
|
+
description = "Auto-generated CRUD admin panel for all product models"
|
|
30
|
+
|
|
31
|
+
[tool.splent.contract.provides]
|
|
32
|
+
routes = ["/admin", "/admin/<model_name>", "/admin/<model_name>/<int:id>", "/admin/<model_name>/<int:id>/delete", "/admin/<model_name>/create"]
|
|
33
|
+
blueprints = []
|
|
34
|
+
models = []
|
|
35
|
+
commands = []
|
|
36
|
+
hooks = ["layout.authenticated_sidebar"]
|
|
37
|
+
services = []
|
|
38
|
+
signals = []
|
|
39
|
+
translations = []
|
|
40
|
+
docker = []
|
|
41
|
+
|
|
42
|
+
[tool.splent.contract.requires]
|
|
43
|
+
features = ["auth"]
|
|
44
|
+
env_vars = []
|
|
45
|
+
signals = []
|
|
46
|
+
|
|
47
|
+
[tool.splent.contract.extensible]
|
|
48
|
+
services = []
|
|
49
|
+
templates = ["admin/dashboard.html", "admin/delete.html", "admin/form.html", "admin/list.html"]
|
|
50
|
+
models = []
|
|
51
|
+
hooks = ["layout.authenticated_sidebar"]
|
|
52
|
+
routes = false
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
MANIFEST.in
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/splent_feature_admin.egg-info/PKG-INFO
|
|
4
|
+
src/splent_feature_admin.egg-info/SOURCES.txt
|
|
5
|
+
src/splent_feature_admin.egg-info/dependency_links.txt
|
|
6
|
+
src/splent_feature_admin.egg-info/top_level.txt
|
|
7
|
+
src/splent_io/splent_feature_admin/__init__.py
|
|
8
|
+
src/splent_io/splent_feature_admin/config.py
|
|
9
|
+
src/splent_io/splent_feature_admin/hooks.py
|
|
10
|
+
src/splent_io/splent_feature_admin/routes.py
|
|
11
|
+
src/splent_io/splent_feature_admin/seeders.py
|
|
12
|
+
src/splent_io/splent_feature_admin/services.py
|
|
13
|
+
src/splent_io/splent_feature_admin/migrations/alembic.ini
|
|
14
|
+
src/splent_io/splent_feature_admin/migrations/env.py
|
|
15
|
+
src/splent_io/splent_feature_admin/migrations/script.py.mako
|
|
16
|
+
src/splent_io/splent_feature_admin/templates/admin/dashboard.html
|
|
17
|
+
src/splent_io/splent_feature_admin/templates/admin/delete.html
|
|
18
|
+
src/splent_io/splent_feature_admin/templates/admin/form.html
|
|
19
|
+
src/splent_io/splent_feature_admin/templates/admin/list.html
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
splent_io
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from splent_framework.blueprints.base_blueprint import create_blueprint
|
|
2
|
+
from splent_framework.services.service_locator import register_service
|
|
3
|
+
|
|
4
|
+
from splent_io.splent_feature_admin.services import AdminService
|
|
5
|
+
|
|
6
|
+
admin_bp = create_blueprint(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def init_feature(app):
|
|
10
|
+
register_service(app, "AdminService", AdminService)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def inject_context_vars(app):
|
|
14
|
+
return {}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from splent_framework.hooks.template_hooks import register_template_hook
|
|
2
|
+
from flask import request, url_for
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def admin_sidebar_link():
|
|
6
|
+
active = "active" if request.endpoint and request.endpoint.startswith("admin.") else ""
|
|
7
|
+
return (
|
|
8
|
+
f'<li class="sidebar-item {active}">'
|
|
9
|
+
f'<a class="sidebar-link" href="{url_for("admin.dashboard")}">'
|
|
10
|
+
'<i class="align-middle" data-feather="settings"></i> '
|
|
11
|
+
'<span class="align-middle">Admin</span>'
|
|
12
|
+
"</a>"
|
|
13
|
+
"</li>"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
register_template_hook("layout.authenticated_sidebar", admin_sidebar_link)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[alembic]
|
|
2
|
+
script_location = migrations
|
|
3
|
+
|
|
4
|
+
[loggers]
|
|
5
|
+
keys = root,sqlalchemy,alembic
|
|
6
|
+
|
|
7
|
+
[handlers]
|
|
8
|
+
keys = console
|
|
9
|
+
|
|
10
|
+
[formatters]
|
|
11
|
+
keys = generic
|
|
12
|
+
|
|
13
|
+
[logger_root]
|
|
14
|
+
level = WARN
|
|
15
|
+
handlers = console
|
|
16
|
+
qualname =
|
|
17
|
+
|
|
18
|
+
[logger_sqlalchemy]
|
|
19
|
+
level = WARN
|
|
20
|
+
handlers =
|
|
21
|
+
qualname = sqlalchemy.engine
|
|
22
|
+
|
|
23
|
+
[logger_alembic]
|
|
24
|
+
level = INFO
|
|
25
|
+
handlers =
|
|
26
|
+
qualname = alembic
|
|
27
|
+
|
|
28
|
+
[handler_console]
|
|
29
|
+
class = StreamHandler
|
|
30
|
+
args = (sys.stderr,)
|
|
31
|
+
level = NOTSET
|
|
32
|
+
formatter = generic
|
|
33
|
+
|
|
34
|
+
[formatter_generic]
|
|
35
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
36
|
+
datefmt = %H:%M:%S
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Alembic migration environment for splent_feature_admin."""
|
|
2
|
+
|
|
3
|
+
from splent_io.splent_feature_admin import models # noqa
|
|
4
|
+
from splent_framework.migrations.feature_env import run_feature_migrations
|
|
5
|
+
|
|
6
|
+
FEATURE_NAME = "splent_feature_admin"
|
|
7
|
+
FEATURE_TABLES = set()
|
|
8
|
+
|
|
9
|
+
run_feature_migrations(FEATURE_NAME, FEATURE_TABLES)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from alembic import op
|
|
9
|
+
import sqlalchemy as sa
|
|
10
|
+
${imports if imports else ""}
|
|
11
|
+
|
|
12
|
+
# revision identifiers, used by Alembic.
|
|
13
|
+
revision = ${repr(up_revision)}
|
|
14
|
+
down_revision = ${repr(down_revision)}
|
|
15
|
+
branch_labels = ${repr(branch_labels)}
|
|
16
|
+
depends_on = ${repr(depends_on)}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def upgrade():
|
|
20
|
+
${upgrades if upgrades else "pass"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def downgrade():
|
|
24
|
+
${downgrades if downgrades else "pass"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from flask import flash, render_template, redirect, url_for, request
|
|
2
|
+
from flask_login import login_required
|
|
3
|
+
|
|
4
|
+
from splent_io.splent_feature_admin import admin_bp
|
|
5
|
+
from splent_io.splent_feature_admin.services import AdminService
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@admin_bp.route("/admin", methods=["GET"])
|
|
9
|
+
@login_required
|
|
10
|
+
def dashboard():
|
|
11
|
+
models = AdminService.get_models()
|
|
12
|
+
model_stats = []
|
|
13
|
+
for name, cls in sorted(models.items()):
|
|
14
|
+
model_stats.append({
|
|
15
|
+
"name": name,
|
|
16
|
+
"count": AdminService.count_records(cls),
|
|
17
|
+
"columns": len(AdminService.get_columns(cls)),
|
|
18
|
+
})
|
|
19
|
+
return render_template("admin/dashboard.html", models=model_stats)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@admin_bp.route("/admin/<model_name>", methods=["GET"])
|
|
23
|
+
@login_required
|
|
24
|
+
def model_list(model_name):
|
|
25
|
+
model = AdminService.get_model(model_name)
|
|
26
|
+
if model is None:
|
|
27
|
+
flash(f"Model '{model_name}' not found.", "danger")
|
|
28
|
+
return redirect(url_for("admin.dashboard"))
|
|
29
|
+
|
|
30
|
+
page = request.args.get("page", 1, type=int)
|
|
31
|
+
pagination = AdminService.get_records(model, page=page)
|
|
32
|
+
columns = AdminService.get_list_columns(model)
|
|
33
|
+
|
|
34
|
+
return render_template(
|
|
35
|
+
"admin/list.html",
|
|
36
|
+
model_name=model_name,
|
|
37
|
+
columns=columns,
|
|
38
|
+
pagination=pagination,
|
|
39
|
+
records=pagination.items,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@admin_bp.route("/admin/<model_name>/create", methods=["GET", "POST"])
|
|
44
|
+
@login_required
|
|
45
|
+
def create(model_name):
|
|
46
|
+
model = AdminService.get_model(model_name)
|
|
47
|
+
if model is None:
|
|
48
|
+
flash(f"Model '{model_name}' not found.", "danger")
|
|
49
|
+
return redirect(url_for("admin.dashboard"))
|
|
50
|
+
|
|
51
|
+
columns = AdminService.get_editable_columns(model)
|
|
52
|
+
|
|
53
|
+
if request.method == "POST":
|
|
54
|
+
AdminService.create_record(model, request.form.to_dict())
|
|
55
|
+
flash(f"{model_name} created successfully.", "success")
|
|
56
|
+
return redirect(url_for("admin.model_list", model_name=model_name))
|
|
57
|
+
|
|
58
|
+
return render_template(
|
|
59
|
+
"admin/form.html",
|
|
60
|
+
model_name=model_name,
|
|
61
|
+
columns=columns,
|
|
62
|
+
record=None,
|
|
63
|
+
action="Create",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@admin_bp.route("/admin/<model_name>/<int:id>", methods=["GET", "POST"])
|
|
68
|
+
@login_required
|
|
69
|
+
def edit(model_name, id):
|
|
70
|
+
model = AdminService.get_model(model_name)
|
|
71
|
+
if model is None:
|
|
72
|
+
flash(f"Model '{model_name}' not found.", "danger")
|
|
73
|
+
return redirect(url_for("admin.dashboard"))
|
|
74
|
+
|
|
75
|
+
record = AdminService.get_record(model, id)
|
|
76
|
+
if record is None:
|
|
77
|
+
flash(f"{model_name} with id {id} not found.", "danger")
|
|
78
|
+
return redirect(url_for("admin.model_list", model_name=model_name))
|
|
79
|
+
|
|
80
|
+
columns = AdminService.get_editable_columns(model)
|
|
81
|
+
|
|
82
|
+
if request.method == "POST":
|
|
83
|
+
AdminService.update_record(record, request.form.to_dict(), model)
|
|
84
|
+
flash(f"{model_name} updated successfully.", "success")
|
|
85
|
+
return redirect(url_for("admin.model_list", model_name=model_name))
|
|
86
|
+
|
|
87
|
+
return render_template(
|
|
88
|
+
"admin/form.html",
|
|
89
|
+
model_name=model_name,
|
|
90
|
+
columns=columns,
|
|
91
|
+
record=record,
|
|
92
|
+
action="Edit",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@admin_bp.route("/admin/<model_name>/<int:id>/delete", methods=["GET", "POST"])
|
|
97
|
+
@login_required
|
|
98
|
+
def delete(model_name, id):
|
|
99
|
+
model = AdminService.get_model(model_name)
|
|
100
|
+
if model is None:
|
|
101
|
+
flash(f"Model '{model_name}' not found.", "danger")
|
|
102
|
+
return redirect(url_for("admin.dashboard"))
|
|
103
|
+
|
|
104
|
+
record = AdminService.get_record(model, id)
|
|
105
|
+
if record is None:
|
|
106
|
+
flash(f"{model_name} with id {id} not found.", "danger")
|
|
107
|
+
return redirect(url_for("admin.model_list", model_name=model_name))
|
|
108
|
+
|
|
109
|
+
if request.method == "POST":
|
|
110
|
+
AdminService.delete_record(record)
|
|
111
|
+
flash(f"{model_name} deleted successfully.", "success")
|
|
112
|
+
return redirect(url_for("admin.model_list", model_name=model_name))
|
|
113
|
+
|
|
114
|
+
columns = AdminService.get_columns(model)
|
|
115
|
+
|
|
116
|
+
return render_template(
|
|
117
|
+
"admin/delete.html",
|
|
118
|
+
model_name=model_name,
|
|
119
|
+
columns=columns,
|
|
120
|
+
record=record,
|
|
121
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from splent_io.splent_feature_auth.models import User
|
|
2
|
+
from splent_framework.seeders.BaseSeeder import BaseSeeder
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AdminSeeder(BaseSeeder):
|
|
6
|
+
priority = 0 # Run before other seeders
|
|
7
|
+
|
|
8
|
+
def run(self):
|
|
9
|
+
admin = User(email="admin@admin.com", password="admin", active=True)
|
|
10
|
+
self.seed([admin])
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from sqlalchemy import inspect as sa_inspect
|
|
2
|
+
|
|
3
|
+
from splent_framework.db import db
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Column types to skip in list views (too verbose for table display)
|
|
7
|
+
_SKIP_TYPES_IN_LIST = {"TEXT", "BLOB", "JSON", "LONGTEXT", "MEDIUMTEXT"}
|
|
8
|
+
|
|
9
|
+
# Maximum columns shown in the list view
|
|
10
|
+
_MAX_LIST_COLUMNS = 7
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AdminService:
|
|
14
|
+
"""Service for auto-generated CRUD operations on all registered models."""
|
|
15
|
+
|
|
16
|
+
# ── Model discovery ──────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def get_models() -> dict[str, type]:
|
|
20
|
+
"""Return all registered SQLAlchemy model classes keyed by name."""
|
|
21
|
+
return {cls.__name__: cls for cls in db.Model.__subclasses__()}
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def get_model(name: str) -> type | None:
|
|
25
|
+
"""Return a single model class by name, or None."""
|
|
26
|
+
models = AdminService.get_models()
|
|
27
|
+
return models.get(name)
|
|
28
|
+
|
|
29
|
+
# ── Column introspection ─────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def get_columns(model: type) -> list[dict]:
|
|
33
|
+
"""Return column metadata for a model via SQLAlchemy inspect."""
|
|
34
|
+
mapper = sa_inspect(model)
|
|
35
|
+
cols = []
|
|
36
|
+
for col in mapper.columns:
|
|
37
|
+
fk = None
|
|
38
|
+
if col.foreign_keys:
|
|
39
|
+
fk = str(list(col.foreign_keys)[0].target_fullname)
|
|
40
|
+
cols.append({
|
|
41
|
+
"name": col.name,
|
|
42
|
+
"type": str(col.type),
|
|
43
|
+
"primary_key": col.primary_key,
|
|
44
|
+
"nullable": col.nullable,
|
|
45
|
+
"foreign_key": fk,
|
|
46
|
+
})
|
|
47
|
+
return cols
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def get_list_columns(model: type) -> list[dict]:
|
|
51
|
+
"""Return columns suitable for the list view (filtered and capped)."""
|
|
52
|
+
cols = AdminService.get_columns(model)
|
|
53
|
+
filtered = [
|
|
54
|
+
c for c in cols
|
|
55
|
+
if c["type"].upper().split("(")[0] not in _SKIP_TYPES_IN_LIST
|
|
56
|
+
]
|
|
57
|
+
return filtered[:_MAX_LIST_COLUMNS]
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def get_editable_columns(model: type) -> list[dict]:
|
|
61
|
+
"""Return columns that can be edited (excludes primary keys)."""
|
|
62
|
+
return [c for c in AdminService.get_columns(model) if not c["primary_key"]]
|
|
63
|
+
|
|
64
|
+
# ── CRUD operations ──────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def get_record(model: type, record_id: int):
|
|
68
|
+
"""Fetch a single record by primary key."""
|
|
69
|
+
return db.session.get(model, record_id)
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def get_records(model: type, page: int = 1, per_page: int = 20):
|
|
73
|
+
"""Return a paginated query for the given model."""
|
|
74
|
+
return model.query.paginate(page=page, per_page=per_page, error_out=False)
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def count_records(model: type) -> int:
|
|
78
|
+
"""Return the total number of records for a model."""
|
|
79
|
+
return model.query.count()
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def create_record(model: type, form_data: dict):
|
|
83
|
+
"""Create a new record from form data."""
|
|
84
|
+
editable = {c["name"] for c in AdminService.get_editable_columns(model)}
|
|
85
|
+
nullable = {c["name"] for c in AdminService.get_columns(model) if c["nullable"]}
|
|
86
|
+
|
|
87
|
+
data = {}
|
|
88
|
+
for key, value in form_data.items():
|
|
89
|
+
if key in editable:
|
|
90
|
+
if value == "" and key in nullable:
|
|
91
|
+
data[key] = None
|
|
92
|
+
else:
|
|
93
|
+
data[key] = value
|
|
94
|
+
|
|
95
|
+
record = model(**data)
|
|
96
|
+
db.session.add(record)
|
|
97
|
+
db.session.commit()
|
|
98
|
+
return record
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def update_record(record, form_data: dict, model: type):
|
|
102
|
+
"""Update an existing record from form data."""
|
|
103
|
+
editable = {c["name"] for c in AdminService.get_editable_columns(model)}
|
|
104
|
+
nullable = {c["name"] for c in AdminService.get_columns(model) if c["nullable"]}
|
|
105
|
+
|
|
106
|
+
for key, value in form_data.items():
|
|
107
|
+
if key in editable:
|
|
108
|
+
if value == "" and key in nullable:
|
|
109
|
+
setattr(record, key, None)
|
|
110
|
+
else:
|
|
111
|
+
setattr(record, key, value)
|
|
112
|
+
|
|
113
|
+
db.session.commit()
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def delete_record(record):
|
|
117
|
+
"""Delete a record from the database."""
|
|
118
|
+
db.session.delete(record)
|
|
119
|
+
db.session.commit()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{% extends "base_template.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}Admin Dashboard{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
|
|
7
|
+
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
8
|
+
<h1 class="h2 mb-0"><b>Admin Dashboard</b></h1>
|
|
9
|
+
<span class="badge bg-secondary fs-6">{{ models|length }} model{{ 's' if models|length != 1 }}</span>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
13
|
+
{% if messages %}
|
|
14
|
+
{% for category, message in messages %}
|
|
15
|
+
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
|
16
|
+
{{ message }}
|
|
17
|
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
18
|
+
</div>
|
|
19
|
+
{% endfor %}
|
|
20
|
+
{% endif %}
|
|
21
|
+
{% endwith %}
|
|
22
|
+
|
|
23
|
+
{% if models %}
|
|
24
|
+
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
|
25
|
+
{% for model in models %}
|
|
26
|
+
<div class="col">
|
|
27
|
+
<div class="card h-100 shadow-sm">
|
|
28
|
+
<div class="card-body d-flex flex-column">
|
|
29
|
+
<h5 class="card-title mb-1">{{ model.name }}</h5>
|
|
30
|
+
<p class="text-muted small mb-3">{{ model.columns }} column{{ 's' if model.columns != 1 }}</p>
|
|
31
|
+
<div class="mt-auto d-flex justify-content-between align-items-center">
|
|
32
|
+
<span class="badge bg-primary rounded-pill fs-6">{{ model.count }}</span>
|
|
33
|
+
<div>
|
|
34
|
+
<a href="{{ url_for('admin.model_list', model_name=model.name) }}" class="btn btn-sm btn-outline-primary">Browse</a>
|
|
35
|
+
<a href="{{ url_for('admin.create', model_name=model.name) }}" class="btn btn-sm btn-outline-success">+ New</a>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
{% endfor %}
|
|
42
|
+
</div>
|
|
43
|
+
{% else %}
|
|
44
|
+
<div class="alert alert-info">
|
|
45
|
+
No models discovered. Make sure your features are loaded and have SQLAlchemy models defined.
|
|
46
|
+
</div>
|
|
47
|
+
{% endif %}
|
|
48
|
+
|
|
49
|
+
{% endblock %}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{% extends "base_template.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}Delete {{ model_name }} - Admin{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block breadcrumb %}
|
|
6
|
+
<nav aria-label="breadcrumb">
|
|
7
|
+
<ol class="breadcrumb">
|
|
8
|
+
<li class="breadcrumb-item"><a href="{{ url_for('admin.dashboard') }}">Admin</a></li>
|
|
9
|
+
<li class="breadcrumb-item"><a href="{{ url_for('admin.model_list', model_name=model_name) }}">{{ model_name }}</a></li>
|
|
10
|
+
<li class="breadcrumb-item active">Delete</li>
|
|
11
|
+
</ol>
|
|
12
|
+
</nav>
|
|
13
|
+
{% endblock %}
|
|
14
|
+
|
|
15
|
+
{% block content %}
|
|
16
|
+
|
|
17
|
+
<h1 class="h2 mb-4"><b>Delete {{ model_name }}</b></h1>
|
|
18
|
+
|
|
19
|
+
<div class="card border-danger shadow-sm">
|
|
20
|
+
<div class="card-body">
|
|
21
|
+
<p class="card-text mb-3">
|
|
22
|
+
You are about to permanently delete this <strong>{{ model_name }}</strong> record. This action cannot be undone.
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
<table class="table table-sm mb-4">
|
|
26
|
+
<tbody>
|
|
27
|
+
{% for col in columns %}
|
|
28
|
+
<tr>
|
|
29
|
+
<th class="text-muted" style="width: 200px;">{{ col.name }}</th>
|
|
30
|
+
<td>
|
|
31
|
+
{% set val = record.__dict__.get(col.name, '') %}
|
|
32
|
+
{% if val is none %}
|
|
33
|
+
<span class="text-muted fst-italic">null</span>
|
|
34
|
+
{% else %}
|
|
35
|
+
{{ val }}
|
|
36
|
+
{% endif %}
|
|
37
|
+
</td>
|
|
38
|
+
</tr>
|
|
39
|
+
{% endfor %}
|
|
40
|
+
</tbody>
|
|
41
|
+
</table>
|
|
42
|
+
|
|
43
|
+
<form method="POST" class="d-inline">
|
|
44
|
+
<button type="submit" class="btn btn-danger">Confirm Delete</button>
|
|
45
|
+
<a href="{{ url_for('admin.model_list', model_name=model_name) }}" class="btn btn-outline-secondary ms-2">Cancel</a>
|
|
46
|
+
</form>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
{% endblock %}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
{% extends "base_template.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}{{ action }} {{ model_name }} - Admin{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block breadcrumb %}
|
|
6
|
+
<nav aria-label="breadcrumb">
|
|
7
|
+
<ol class="breadcrumb">
|
|
8
|
+
<li class="breadcrumb-item"><a href="{{ url_for('admin.dashboard') }}">Admin</a></li>
|
|
9
|
+
<li class="breadcrumb-item"><a href="{{ url_for('admin.model_list', model_name=model_name) }}">{{ model_name }}</a></li>
|
|
10
|
+
<li class="breadcrumb-item active">{{ action }}</li>
|
|
11
|
+
</ol>
|
|
12
|
+
</nav>
|
|
13
|
+
{% endblock %}
|
|
14
|
+
|
|
15
|
+
{% block content %}
|
|
16
|
+
|
|
17
|
+
<h1 class="h2 mb-4"><b>{{ action }} {{ model_name }}</b></h1>
|
|
18
|
+
|
|
19
|
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
20
|
+
{% if messages %}
|
|
21
|
+
{% for category, message in messages %}
|
|
22
|
+
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
|
23
|
+
{{ message }}
|
|
24
|
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
25
|
+
</div>
|
|
26
|
+
{% endfor %}
|
|
27
|
+
{% endif %}
|
|
28
|
+
{% endwith %}
|
|
29
|
+
|
|
30
|
+
<form method="POST">
|
|
31
|
+
<div class="row">
|
|
32
|
+
{% for col in columns %}
|
|
33
|
+
<div class="col-md-6 mb-3">
|
|
34
|
+
<label for="{{ col.name }}" class="form-label fw-semibold">
|
|
35
|
+
{{ col.name }}
|
|
36
|
+
<span class="text-muted fw-normal small">({{ col.type }})</span>
|
|
37
|
+
{% if col.foreign_key %}
|
|
38
|
+
<span class="badge bg-info text-dark">FK: {{ col.foreign_key }}</span>
|
|
39
|
+
{% endif %}
|
|
40
|
+
{% if not col.nullable %}
|
|
41
|
+
<span class="text-danger">*</span>
|
|
42
|
+
{% endif %}
|
|
43
|
+
</label>
|
|
44
|
+
|
|
45
|
+
{% set col_type = col.type.upper().split('(')[0] %}
|
|
46
|
+
{% if col_type in ('TEXT', 'LONGTEXT', 'MEDIUMTEXT') %}
|
|
47
|
+
<textarea
|
|
48
|
+
id="{{ col.name }}"
|
|
49
|
+
name="{{ col.name }}"
|
|
50
|
+
class="form-control"
|
|
51
|
+
rows="4"
|
|
52
|
+
{{ 'required' if not col.nullable }}
|
|
53
|
+
>{{ record[col.name] if record and record.__dict__.get(col.name) is not none else '' }}</textarea>
|
|
54
|
+
{% elif col_type == 'BOOLEAN' or col_type == 'TINYINT' %}
|
|
55
|
+
<div class="form-check form-switch mt-2">
|
|
56
|
+
<input
|
|
57
|
+
type="checkbox"
|
|
58
|
+
id="{{ col.name }}"
|
|
59
|
+
name="{{ col.name }}"
|
|
60
|
+
value="1"
|
|
61
|
+
class="form-check-input"
|
|
62
|
+
{{ 'checked' if record and record.__dict__.get(col.name) }}
|
|
63
|
+
>
|
|
64
|
+
</div>
|
|
65
|
+
{% else %}
|
|
66
|
+
<input
|
|
67
|
+
type="{{ 'number' if col_type in ('INTEGER', 'INT', 'BIGINT', 'SMALLINT', 'FLOAT', 'DECIMAL', 'NUMERIC', 'DOUBLE') else 'text' }}"
|
|
68
|
+
id="{{ col.name }}"
|
|
69
|
+
name="{{ col.name }}"
|
|
70
|
+
class="form-control"
|
|
71
|
+
value="{{ record.__dict__.get(col.name, '') if record and record.__dict__.get(col.name) is not none else '' }}"
|
|
72
|
+
{{ 'required' if not col.nullable }}
|
|
73
|
+
{{ 'step=any' if col_type in ('FLOAT', 'DECIMAL', 'NUMERIC', 'DOUBLE') }}
|
|
74
|
+
>
|
|
75
|
+
{% endif %}
|
|
76
|
+
</div>
|
|
77
|
+
{% endfor %}
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div class="mt-3">
|
|
81
|
+
<button type="submit" class="btn btn-primary">
|
|
82
|
+
{{ 'Save changes' if action == 'Edit' else 'Create' }}
|
|
83
|
+
</button>
|
|
84
|
+
<a href="{{ url_for('admin.model_list', model_name=model_name) }}" class="btn btn-outline-secondary ms-2">Cancel</a>
|
|
85
|
+
</div>
|
|
86
|
+
</form>
|
|
87
|
+
|
|
88
|
+
{% endblock %}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
{% extends "base_template.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}{{ model_name }} - Admin{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block breadcrumb %}
|
|
6
|
+
<nav aria-label="breadcrumb">
|
|
7
|
+
<ol class="breadcrumb">
|
|
8
|
+
<li class="breadcrumb-item"><a href="{{ url_for('admin.dashboard') }}">Admin</a></li>
|
|
9
|
+
<li class="breadcrumb-item active">{{ model_name }}</li>
|
|
10
|
+
</ol>
|
|
11
|
+
</nav>
|
|
12
|
+
{% endblock %}
|
|
13
|
+
|
|
14
|
+
{% block content %}
|
|
15
|
+
|
|
16
|
+
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
17
|
+
<h1 class="h2 mb-0"><b>{{ model_name }}</b></h1>
|
|
18
|
+
<a href="{{ url_for('admin.create', model_name=model_name) }}" class="btn btn-success">+ New {{ model_name }}</a>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
22
|
+
{% if messages %}
|
|
23
|
+
{% for category, message in messages %}
|
|
24
|
+
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
|
25
|
+
{{ message }}
|
|
26
|
+
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
27
|
+
</div>
|
|
28
|
+
{% endfor %}
|
|
29
|
+
{% endif %}
|
|
30
|
+
{% endwith %}
|
|
31
|
+
|
|
32
|
+
{% if records %}
|
|
33
|
+
<div class="table-responsive">
|
|
34
|
+
<table class="table table-striped table-hover align-middle">
|
|
35
|
+
<thead class="table-dark">
|
|
36
|
+
<tr>
|
|
37
|
+
{% for col in columns %}
|
|
38
|
+
<th>
|
|
39
|
+
{{ col.name }}
|
|
40
|
+
{% if col.primary_key %}<span class="badge bg-warning text-dark">PK</span>{% endif %}
|
|
41
|
+
{% if col.foreign_key %}<span class="badge bg-info text-dark">FK</span>{% endif %}
|
|
42
|
+
</th>
|
|
43
|
+
{% endfor %}
|
|
44
|
+
<th class="text-end">Actions</th>
|
|
45
|
+
</tr>
|
|
46
|
+
</thead>
|
|
47
|
+
<tbody>
|
|
48
|
+
{% for record in records %}
|
|
49
|
+
<tr>
|
|
50
|
+
{% for col in columns %}
|
|
51
|
+
<td>
|
|
52
|
+
{% set val = record.__dict__.get(col.name, '') %}
|
|
53
|
+
{% if val is none %}
|
|
54
|
+
<span class="text-muted fst-italic">null</span>
|
|
55
|
+
{% elif val|string|length > 80 %}
|
|
56
|
+
{{ val|string|truncate(80) }}
|
|
57
|
+
{% else %}
|
|
58
|
+
{{ val }}
|
|
59
|
+
{% endif %}
|
|
60
|
+
</td>
|
|
61
|
+
{% endfor %}
|
|
62
|
+
<td class="text-end text-nowrap">
|
|
63
|
+
<a href="{{ url_for('admin.edit', model_name=model_name, id=record.id) }}" class="btn btn-sm btn-outline-primary">Edit</a>
|
|
64
|
+
<a href="{{ url_for('admin.delete', model_name=model_name, id=record.id) }}" class="btn btn-sm btn-outline-danger">Delete</a>
|
|
65
|
+
</td>
|
|
66
|
+
</tr>
|
|
67
|
+
{% endfor %}
|
|
68
|
+
</tbody>
|
|
69
|
+
</table>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
{% if pagination.pages > 1 %}
|
|
73
|
+
<nav aria-label="Pagination">
|
|
74
|
+
<ul class="pagination justify-content-center">
|
|
75
|
+
<li class="page-item {{ 'disabled' if not pagination.has_prev }}">
|
|
76
|
+
<a class="page-link" href="{{ url_for('admin.model_list', model_name=model_name, page=pagination.prev_num) }}">Previous</a>
|
|
77
|
+
</li>
|
|
78
|
+
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
|
79
|
+
{% if p %}
|
|
80
|
+
<li class="page-item {{ 'active' if p == pagination.page }}">
|
|
81
|
+
<a class="page-link" href="{{ url_for('admin.model_list', model_name=model_name, page=p) }}">{{ p }}</a>
|
|
82
|
+
</li>
|
|
83
|
+
{% else %}
|
|
84
|
+
<li class="page-item disabled"><span class="page-link">...</span></li>
|
|
85
|
+
{% endif %}
|
|
86
|
+
{% endfor %}
|
|
87
|
+
<li class="page-item {{ 'disabled' if not pagination.has_next }}">
|
|
88
|
+
<a class="page-link" href="{{ url_for('admin.model_list', model_name=model_name, page=pagination.next_num) }}">Next</a>
|
|
89
|
+
</li>
|
|
90
|
+
</ul>
|
|
91
|
+
</nav>
|
|
92
|
+
{% endif %}
|
|
93
|
+
|
|
94
|
+
{% else %}
|
|
95
|
+
<div class="alert alert-info">
|
|
96
|
+
No records found for <strong>{{ model_name }}</strong>.
|
|
97
|
+
<a href="{{ url_for('admin.create', model_name=model_name) }}">Create the first one.</a>
|
|
98
|
+
</div>
|
|
99
|
+
{% endif %}
|
|
100
|
+
|
|
101
|
+
{% endblock %}
|