fast-feature-admin 0.0.1__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.
- fast_feature/admin/__init__.py +6 -0
- fast_feature/admin/py.typed +0 -0
- fast_feature/admin/router/__init__.py +5 -0
- fast_feature/admin/router/router.py +241 -0
- fast_feature/admin/schemas/__init__.py +8 -0
- fast_feature/admin/schemas/flag_create.py +26 -0
- fast_feature/admin/schemas/flag_toggle.py +7 -0
- fast_feature/admin/schemas/flag_update.py +25 -0
- fast_feature/admin/schemas/flag_view.py +32 -0
- fast_feature/admin/service/__init__.py +5 -0
- fast_feature/admin/service/management_service.py +32 -0
- fast_feature/admin/templates/base.html +24 -0
- fast_feature/admin/templates/form.html +29 -0
- fast_feature/admin/templates/list.html +38 -0
- fast_feature_admin-0.0.1.dist-info/METADATA +23 -0
- fast_feature_admin-0.0.1.dist-info/RECORD +17 -0
- fast_feature_admin-0.0.1.dist-info/WHEEL +4 -0
|
File without changes
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, HTTPException, Request, Response
|
|
9
|
+
from fastapi.params import Depends
|
|
10
|
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
11
|
+
from fastapi.templating import Jinja2Templates
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
from starlette.datastructures import FormData
|
|
14
|
+
|
|
15
|
+
from fast_feature.core import (
|
|
16
|
+
FlagAlreadyExistsError,
|
|
17
|
+
FlagNotFoundError,
|
|
18
|
+
FlagRepository,
|
|
19
|
+
InvalidFlagError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from ..schemas import FlagCreate, FlagToggle, FlagUpdate, FlagView
|
|
23
|
+
from ..service import ManagementService
|
|
24
|
+
|
|
25
|
+
_TEMPLATES_DIR = Path(__file__).resolve().parents[1] / "templates"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AdminRouter:
|
|
29
|
+
"""Builds a pluggable admin ``APIRouter``: JSON CRUD under ``/api/flags``
|
|
30
|
+
plus an optional server-rendered console under ``/``.
|
|
31
|
+
|
|
32
|
+
app.include_router(AdminRouter.build(repository), prefix="/admin")
|
|
33
|
+
|
|
34
|
+
Pass ``dependencies`` (e.g. an auth guard) to protect every route.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
repository: FlagRepository,
|
|
40
|
+
*,
|
|
41
|
+
dependencies: Sequence[Depends] | None = None,
|
|
42
|
+
ui: bool = True,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._service = ManagementService(repository)
|
|
45
|
+
self._router = APIRouter(dependencies=list(dependencies or []))
|
|
46
|
+
self._templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
|
47
|
+
self._register_json_routes()
|
|
48
|
+
if ui:
|
|
49
|
+
self._register_ui_routes()
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def router(self) -> APIRouter:
|
|
53
|
+
return self._router
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def build(
|
|
57
|
+
cls,
|
|
58
|
+
repository: FlagRepository,
|
|
59
|
+
*,
|
|
60
|
+
dependencies: Sequence[Depends] | None = None,
|
|
61
|
+
ui: bool = True,
|
|
62
|
+
) -> APIRouter:
|
|
63
|
+
return cls(repository, dependencies=dependencies, ui=ui).router
|
|
64
|
+
|
|
65
|
+
# --- JSON API ------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def _register_json_routes(self) -> None:
|
|
68
|
+
router = self._router
|
|
69
|
+
router.add_api_route(
|
|
70
|
+
"/api/flags", self._list, methods=["GET"], response_model=list[FlagView]
|
|
71
|
+
)
|
|
72
|
+
router.add_api_route(
|
|
73
|
+
"/api/flags", self._create, methods=["POST"], response_model=FlagView, status_code=201
|
|
74
|
+
)
|
|
75
|
+
router.add_api_route(
|
|
76
|
+
"/api/flags/{key}", self._get, methods=["GET"], response_model=FlagView
|
|
77
|
+
)
|
|
78
|
+
router.add_api_route(
|
|
79
|
+
"/api/flags/{key}", self._update, methods=["PUT"], response_model=FlagView
|
|
80
|
+
)
|
|
81
|
+
router.add_api_route(
|
|
82
|
+
"/api/flags/{key}", self._toggle, methods=["PATCH"], response_model=FlagView
|
|
83
|
+
)
|
|
84
|
+
router.add_api_route("/api/flags/{key}", self._delete, methods=["DELETE"], status_code=204)
|
|
85
|
+
|
|
86
|
+
async def _list(self) -> list[FlagView]:
|
|
87
|
+
return [FlagView.from_flag(flag) for flag in await self._service.list_flags()]
|
|
88
|
+
|
|
89
|
+
async def _create(self, body: FlagCreate) -> FlagView:
|
|
90
|
+
try:
|
|
91
|
+
flag = body.to_flag()
|
|
92
|
+
except InvalidFlagError as exc:
|
|
93
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
94
|
+
try:
|
|
95
|
+
created = await self._service.create_flag(flag)
|
|
96
|
+
except FlagAlreadyExistsError as exc:
|
|
97
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
98
|
+
return FlagView.from_flag(created)
|
|
99
|
+
|
|
100
|
+
async def _get(self, key: str) -> FlagView:
|
|
101
|
+
flag = await self._service.get_flag(key)
|
|
102
|
+
if flag is None:
|
|
103
|
+
raise HTTPException(status_code=404, detail=f"Flag {key!r} was not found")
|
|
104
|
+
return FlagView.from_flag(flag)
|
|
105
|
+
|
|
106
|
+
async def _update(self, key: str, body: FlagUpdate) -> FlagView:
|
|
107
|
+
try:
|
|
108
|
+
flag = body.to_flag(key)
|
|
109
|
+
except InvalidFlagError as exc:
|
|
110
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
111
|
+
try:
|
|
112
|
+
updated = await self._service.update_flag(flag)
|
|
113
|
+
except FlagNotFoundError as exc:
|
|
114
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
115
|
+
return FlagView.from_flag(updated)
|
|
116
|
+
|
|
117
|
+
async def _toggle(self, key: str, body: FlagToggle) -> FlagView:
|
|
118
|
+
try:
|
|
119
|
+
flag = await self._service.toggle(key, enabled=body.enabled)
|
|
120
|
+
except FlagNotFoundError as exc:
|
|
121
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
122
|
+
return FlagView.from_flag(flag)
|
|
123
|
+
|
|
124
|
+
async def _delete(self, key: str) -> Response:
|
|
125
|
+
try:
|
|
126
|
+
await self._service.delete_flag(key)
|
|
127
|
+
except FlagNotFoundError as exc:
|
|
128
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
129
|
+
return Response(status_code=204)
|
|
130
|
+
|
|
131
|
+
# --- web console ---------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def _register_ui_routes(self) -> None:
|
|
134
|
+
router = self._router
|
|
135
|
+
router.add_api_route(
|
|
136
|
+
"/", self._ui_list, methods=["GET"], response_class=HTMLResponse, name="admin:list"
|
|
137
|
+
)
|
|
138
|
+
router.add_api_route(
|
|
139
|
+
"/new", self._ui_new, methods=["GET"], response_class=HTMLResponse, name="admin:new"
|
|
140
|
+
)
|
|
141
|
+
router.add_api_route("/new", self._ui_create, methods=["POST"], name="admin:create")
|
|
142
|
+
router.add_api_route(
|
|
143
|
+
"/{key}/edit",
|
|
144
|
+
self._ui_edit,
|
|
145
|
+
methods=["GET"],
|
|
146
|
+
response_class=HTMLResponse,
|
|
147
|
+
name="admin:edit",
|
|
148
|
+
)
|
|
149
|
+
router.add_api_route("/{key}/edit", self._ui_update, methods=["POST"], name="admin:update")
|
|
150
|
+
router.add_api_route(
|
|
151
|
+
"/{key}/toggle", self._ui_toggle, methods=["POST"], name="admin:toggle"
|
|
152
|
+
)
|
|
153
|
+
router.add_api_route(
|
|
154
|
+
"/{key}/delete", self._ui_delete, methods=["POST"], name="admin:delete"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
async def _ui_list(self, request: Request) -> Response:
|
|
158
|
+
flags = await self._service.list_flags()
|
|
159
|
+
return self._templates.TemplateResponse(request, "list.html", {"flags": flags})
|
|
160
|
+
|
|
161
|
+
async def _ui_new(self, request: Request) -> Response:
|
|
162
|
+
return self._render_form(request, flag=None, error=None)
|
|
163
|
+
|
|
164
|
+
async def _ui_create(self, request: Request) -> Response:
|
|
165
|
+
form = await request.form()
|
|
166
|
+
try:
|
|
167
|
+
flag = FlagCreate(**self._parse_form(form, with_key=True)).to_flag()
|
|
168
|
+
await self._service.create_flag(flag)
|
|
169
|
+
except (InvalidFlagError, FlagAlreadyExistsError, ValidationError, ValueError) as exc:
|
|
170
|
+
return self._render_form(request, flag=None, error=str(exc), status_code=400)
|
|
171
|
+
return RedirectResponse(str(request.url_for("admin:list")), status_code=303)
|
|
172
|
+
|
|
173
|
+
async def _ui_edit(self, request: Request, key: str) -> Response:
|
|
174
|
+
flag = await self._service.get_flag(key)
|
|
175
|
+
if flag is None:
|
|
176
|
+
raise HTTPException(status_code=404, detail=f"Flag {key!r} was not found")
|
|
177
|
+
return self._render_form(request, flag=flag, error=None)
|
|
178
|
+
|
|
179
|
+
async def _ui_update(self, request: Request, key: str) -> Response:
|
|
180
|
+
form = await request.form()
|
|
181
|
+
try:
|
|
182
|
+
flag = FlagUpdate(**self._parse_form(form, with_key=False)).to_flag(key)
|
|
183
|
+
await self._service.update_flag(flag)
|
|
184
|
+
except (InvalidFlagError, FlagNotFoundError, ValidationError, ValueError) as exc:
|
|
185
|
+
existing = await self._service.get_flag(key)
|
|
186
|
+
return self._render_form(request, flag=existing, error=str(exc), status_code=400)
|
|
187
|
+
return RedirectResponse(str(request.url_for("admin:list")), status_code=303)
|
|
188
|
+
|
|
189
|
+
async def _ui_toggle(self, request: Request, key: str) -> Response:
|
|
190
|
+
form = await request.form()
|
|
191
|
+
enabled = self._text(form, "enabled").lower() in ("1", "true", "on", "yes")
|
|
192
|
+
try:
|
|
193
|
+
await self._service.toggle(key, enabled=enabled)
|
|
194
|
+
except FlagNotFoundError as exc:
|
|
195
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
196
|
+
return RedirectResponse(str(request.url_for("admin:list")), status_code=303)
|
|
197
|
+
|
|
198
|
+
async def _ui_delete(self, request: Request, key: str) -> Response:
|
|
199
|
+
try:
|
|
200
|
+
await self._service.delete_flag(key)
|
|
201
|
+
except FlagNotFoundError as exc:
|
|
202
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
203
|
+
return RedirectResponse(str(request.url_for("admin:list")), status_code=303)
|
|
204
|
+
|
|
205
|
+
# --- helpers -------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def _render_form(
|
|
208
|
+
self, request: Request, *, flag: Any, error: str | None, status_code: int = 200
|
|
209
|
+
) -> Response:
|
|
210
|
+
context = {
|
|
211
|
+
"flag": flag,
|
|
212
|
+
"error": error,
|
|
213
|
+
"variants_json": json.dumps(flag.variants, indent=2) if flag else "",
|
|
214
|
+
"targeting_json": json.dumps(flag.targeting, indent=2)
|
|
215
|
+
if flag and flag.targeting
|
|
216
|
+
else "",
|
|
217
|
+
"metadata_json": json.dumps(flag.metadata, indent=2) if flag and flag.metadata else "",
|
|
218
|
+
}
|
|
219
|
+
return self._templates.TemplateResponse(
|
|
220
|
+
request, "form.html", context, status_code=status_code
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def _parse_form(cls, form: FormData, *, with_key: bool) -> dict[str, Any]:
|
|
225
|
+
data: dict[str, Any] = {
|
|
226
|
+
"variants": json.loads(cls._text(form, "variants") or "{}"),
|
|
227
|
+
"default_variant": cls._text(form, "default_variant").strip(),
|
|
228
|
+
"state": cls._text(form, "state") or "ENABLED",
|
|
229
|
+
}
|
|
230
|
+
if with_key:
|
|
231
|
+
data["key"] = cls._text(form, "key").strip()
|
|
232
|
+
targeting = cls._text(form, "targeting").strip()
|
|
233
|
+
data["targeting"] = json.loads(targeting) if targeting else None
|
|
234
|
+
metadata = cls._text(form, "metadata").strip()
|
|
235
|
+
data["metadata"] = json.loads(metadata) if metadata else {}
|
|
236
|
+
return data
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def _text(form: FormData, name: str) -> str:
|
|
240
|
+
value = form.get(name)
|
|
241
|
+
return value if isinstance(value, str) else ""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from fast_feature.core import Flag, FlagState
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FlagCreate(BaseModel):
|
|
11
|
+
key: str
|
|
12
|
+
variants: dict[str, Any]
|
|
13
|
+
default_variant: str
|
|
14
|
+
state: FlagState = FlagState.ENABLED
|
|
15
|
+
targeting: dict[str, Any] | None = None
|
|
16
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
17
|
+
|
|
18
|
+
def to_flag(self) -> Flag:
|
|
19
|
+
return Flag(
|
|
20
|
+
key=self.key,
|
|
21
|
+
variants=self.variants,
|
|
22
|
+
default_variant=self.default_variant,
|
|
23
|
+
state=self.state,
|
|
24
|
+
targeting=self.targeting,
|
|
25
|
+
metadata=self.metadata,
|
|
26
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from fast_feature.core import Flag, FlagState
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FlagUpdate(BaseModel):
|
|
11
|
+
variants: dict[str, Any]
|
|
12
|
+
default_variant: str
|
|
13
|
+
state: FlagState = FlagState.ENABLED
|
|
14
|
+
targeting: dict[str, Any] | None = None
|
|
15
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
def to_flag(self, key: str) -> Flag:
|
|
18
|
+
return Flag(
|
|
19
|
+
key=key,
|
|
20
|
+
variants=self.variants,
|
|
21
|
+
default_variant=self.default_variant,
|
|
22
|
+
state=self.state,
|
|
23
|
+
targeting=self.targeting,
|
|
24
|
+
metadata=self.metadata,
|
|
25
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from fast_feature.core import Flag, FlagState
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FlagView(BaseModel):
|
|
12
|
+
key: str
|
|
13
|
+
variants: dict[str, Any]
|
|
14
|
+
default_variant: str
|
|
15
|
+
state: FlagState
|
|
16
|
+
targeting: dict[str, Any] | None = None
|
|
17
|
+
metadata: dict[str, Any] = {}
|
|
18
|
+
created_at: datetime | None = None
|
|
19
|
+
updated_at: datetime | None = None
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_flag(cls, flag: Flag) -> FlagView:
|
|
23
|
+
return cls(
|
|
24
|
+
key=flag.key,
|
|
25
|
+
variants=flag.variants,
|
|
26
|
+
default_variant=flag.default_variant,
|
|
27
|
+
state=flag.state,
|
|
28
|
+
targeting=flag.targeting,
|
|
29
|
+
metadata=flag.metadata,
|
|
30
|
+
created_at=flag.created_at,
|
|
31
|
+
updated_at=flag.updated_at,
|
|
32
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fast_feature.core import Flag, FlagNotFoundError, FlagRepository, FlagState
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ManagementService:
|
|
7
|
+
"""Flag administration use cases on top of a ``FlagRepository``."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, repository: FlagRepository) -> None:
|
|
10
|
+
self._repository = repository
|
|
11
|
+
|
|
12
|
+
async def list_flags(self) -> list[Flag]:
|
|
13
|
+
return await self._repository.list_all()
|
|
14
|
+
|
|
15
|
+
async def get_flag(self, key: str) -> Flag | None:
|
|
16
|
+
return await self._repository.get(key)
|
|
17
|
+
|
|
18
|
+
async def create_flag(self, flag: Flag) -> Flag:
|
|
19
|
+
return await self._repository.create(flag)
|
|
20
|
+
|
|
21
|
+
async def update_flag(self, flag: Flag) -> Flag:
|
|
22
|
+
return await self._repository.update(flag)
|
|
23
|
+
|
|
24
|
+
async def delete_flag(self, key: str) -> None:
|
|
25
|
+
await self._repository.delete(key)
|
|
26
|
+
|
|
27
|
+
async def toggle(self, key: str, *, enabled: bool) -> Flag:
|
|
28
|
+
flag = await self._repository.get(key)
|
|
29
|
+
if flag is None:
|
|
30
|
+
raise FlagNotFoundError(key)
|
|
31
|
+
flag.state = FlagState.ENABLED if enabled else FlagState.DISABLED
|
|
32
|
+
return await self._repository.update(flag)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{% block title %}fast-feature admin{% endblock %}</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: light dark; }
|
|
9
|
+
body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 60rem; padding: 0 1rem; }
|
|
10
|
+
h1 a { color: inherit; text-decoration: none; }
|
|
11
|
+
table { width: 100%; border-collapse: collapse; }
|
|
12
|
+
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #8884; }
|
|
13
|
+
form.inline { display: inline; }
|
|
14
|
+
label { display: block; margin: 0.75rem 0; }
|
|
15
|
+
textarea { width: 100%; min-height: 6rem; font-family: ui-monospace, monospace; }
|
|
16
|
+
.error { color: #b00020; font-weight: 600; }
|
|
17
|
+
button, a.button { cursor: pointer; }
|
|
18
|
+
</style>
|
|
19
|
+
</head>
|
|
20
|
+
<body>
|
|
21
|
+
<header><h1><a href="{{ request.url_for('admin:list') }}">fast-feature</a></h1></header>
|
|
22
|
+
<main>{% block content %}{% endblock %}</main>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block content %}
|
|
3
|
+
<p><a href="{{ request.url_for('admin:list') }}">← Back</a></p>
|
|
4
|
+
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
|
5
|
+
<form method="post"
|
|
6
|
+
action="{{ request.url_for('admin:create') if flag is none
|
|
7
|
+
else request.url_for('admin:update', key=flag.key) }}">
|
|
8
|
+
{% if flag is none %}
|
|
9
|
+
<label>Key <input name="key" required /></label>
|
|
10
|
+
{% else %}
|
|
11
|
+
<p><strong>Key:</strong> {{ flag.key }}</p>
|
|
12
|
+
{% endif %}
|
|
13
|
+
<label>Default variant
|
|
14
|
+
<input name="default_variant" value="{{ flag.default_variant if flag else '' }}" required />
|
|
15
|
+
</label>
|
|
16
|
+
<label>State
|
|
17
|
+
<select name="state">
|
|
18
|
+
<option value="ENABLED"
|
|
19
|
+
{{ 'selected' if not flag or flag.state.value == 'ENABLED' else '' }}>ENABLED</option>
|
|
20
|
+
<option value="DISABLED"
|
|
21
|
+
{{ 'selected' if flag and flag.state.value == 'DISABLED' else '' }}>DISABLED</option>
|
|
22
|
+
</select>
|
|
23
|
+
</label>
|
|
24
|
+
<label>Variants (JSON)<textarea name="variants" required>{{ variants_json }}</textarea></label>
|
|
25
|
+
<label>Targeting (JSON, optional)<textarea name="targeting">{{ targeting_json }}</textarea></label>
|
|
26
|
+
<label>Metadata (JSON, optional)<textarea name="metadata">{{ metadata_json }}</textarea></label>
|
|
27
|
+
<button type="submit">Save</button>
|
|
28
|
+
</form>
|
|
29
|
+
{% endblock %}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block content %}
|
|
3
|
+
<p><a class="button" href="{{ request.url_for('admin:new') }}">New flag</a></p>
|
|
4
|
+
{% if not flags %}
|
|
5
|
+
<p>No flags yet.</p>
|
|
6
|
+
{% else %}
|
|
7
|
+
<table>
|
|
8
|
+
<thead>
|
|
9
|
+
<tr><th>Key</th><th>State</th><th>Default</th><th>Variants</th><th>Actions</th></tr>
|
|
10
|
+
</thead>
|
|
11
|
+
<tbody>
|
|
12
|
+
{% for flag in flags %}
|
|
13
|
+
<tr>
|
|
14
|
+
<td>{{ flag.key }}</td>
|
|
15
|
+
<td>{{ flag.state.value }}</td>
|
|
16
|
+
<td>{{ flag.default_variant }}</td>
|
|
17
|
+
<td>{{ flag.variants | length }}</td>
|
|
18
|
+
<td>
|
|
19
|
+
<form class="inline" method="post"
|
|
20
|
+
action="{{ request.url_for('admin:toggle', key=flag.key) }}">
|
|
21
|
+
<input type="hidden" name="enabled"
|
|
22
|
+
value="{{ 'false' if flag.state.value == 'ENABLED' else 'true' }}" />
|
|
23
|
+
<button type="submit">
|
|
24
|
+
{{ 'Disable' if flag.state.value == 'ENABLED' else 'Enable' }}
|
|
25
|
+
</button>
|
|
26
|
+
</form>
|
|
27
|
+
<a href="{{ request.url_for('admin:edit', key=flag.key) }}">Edit</a>
|
|
28
|
+
<form class="inline" method="post"
|
|
29
|
+
action="{{ request.url_for('admin:delete', key=flag.key) }}">
|
|
30
|
+
<button type="submit">Delete</button>
|
|
31
|
+
</form>
|
|
32
|
+
</td>
|
|
33
|
+
</tr>
|
|
34
|
+
{% endfor %}
|
|
35
|
+
</tbody>
|
|
36
|
+
</table>
|
|
37
|
+
{% endif %}
|
|
38
|
+
{% endblock %}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fast-feature-admin
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Admin JSON API and server-rendered web console for fast-feature.
|
|
5
|
+
Author: byunjuneseok
|
|
6
|
+
Author-email: byunjuneseok <byunjuneseok@gmail.com>
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Framework :: FastAPI
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: fast-feature-core==0.0.1
|
|
19
|
+
Requires-Dist: fastapi>=0.110
|
|
20
|
+
Requires-Dist: pydantic>=2.6
|
|
21
|
+
Requires-Dist: jinja2>=3.1
|
|
22
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
23
|
+
Requires-Python: >=3.10
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
fast_feature/admin/__init__.py,sha256=gjmFErC-6by3DYyl61NslWoZLClsprudg27jX8heRKo,155
|
|
2
|
+
fast_feature/admin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
fast_feature/admin/router/__init__.py,sha256=mDP0NC-bxH8P9ubZx_l5gQnUFYhpmoGiwKFlUw6sT2o,95
|
|
4
|
+
fast_feature/admin/router/router.py,sha256=PVbMQps-XlcjcbwF6ETNtYlveys60-7-9jgpkLsTBns,9809
|
|
5
|
+
fast_feature/admin/schemas/__init__.py,sha256=w6cNLXCKAYJYRbUO2k4U8JMg9vfpzQEq1V4VePsh_tI,242
|
|
6
|
+
fast_feature/admin/schemas/flag_create.py,sha256=VDxPdFckDFw79ACjuHCzl-WZr4A8Gfw7unO_tjvZB_g,666
|
|
7
|
+
fast_feature/admin/schemas/flag_toggle.py,sha256=R0ejcU_dw2C52HE9MCmITUsqALpyQyTmQSYXXDBD0F8,116
|
|
8
|
+
fast_feature/admin/schemas/flag_update.py,sha256=8ZR5CemHm_uwRUlIQtGRD4zRJ1P-iGFZyWv-cKQnqzY,658
|
|
9
|
+
fast_feature/admin/schemas/flag_view.py,sha256=veJDK1Z-0BSpxGKz4z6Dfuc_siEC1G9b9ReBX01c8bs,833
|
|
10
|
+
fast_feature/admin/service/__init__.py,sha256=Ry_CqUawakjdTRvambtEan7D3B18q78qWfAAobA5CnI,119
|
|
11
|
+
fast_feature/admin/service/management_service.py,sha256=NH0T3i8L0UAFxD9sHzBOnT970NCu4NG84QDNmhIXJh4,1122
|
|
12
|
+
fast_feature/admin/templates/base.html,sha256=paVmDd94VfIIE_MY1_hjipykBDWH8AUbqWDeEnMRGyE,1035
|
|
13
|
+
fast_feature/admin/templates/form.html,sha256=aJh5Bh0rThbpUz0jQIPQ8bE9CYP4kACfQPl7RTGHTno,1344
|
|
14
|
+
fast_feature/admin/templates/list.html,sha256=GxpT-Ik07wcWJUJw3n3sNs7vIeG-ae6AQ5vs9GGM0nU,1423
|
|
15
|
+
fast_feature_admin-0.0.1.dist-info/WHEEL,sha256=8ZlpUMJ7mlDirmlHRhDirEx_nPnARrwDjeE92mlk68E,81
|
|
16
|
+
fast_feature_admin-0.0.1.dist-info/METADATA,sha256=VJRvJtH7fU9qfKourrU7FaFEaphHvsctQB3nwPZFIqk,878
|
|
17
|
+
fast_feature_admin-0.0.1.dist-info/RECORD,,
|