hyperflask 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.
Potentially problematic release.
This version of hyperflask might be problematic. Click here for more details.
- hyperflask/__init__.py +15 -0
- hyperflask/__main__.py +3 -0
- hyperflask/api.py +57 -0
- hyperflask/app.py +176 -0
- hyperflask/cli.py +140 -0
- hyperflask/components/__init__.py +54 -0
- hyperflask/components/alpine.py +16 -0
- hyperflask/components/core.py +60 -0
- hyperflask/components/webcomponent.py +23 -0
- hyperflask/factory.py +173 -0
- hyperflask/forms/__init__.py +51 -0
- hyperflask/forms/fields.py +17 -0
- hyperflask/forms/widgets.py +19 -0
- hyperflask/layout.html +28 -0
- hyperflask/page_helpers.py +36 -0
- hyperflask/reactivity.py +46 -0
- hyperflask/resources.py +149 -0
- hyperflask/static/alpine.js +5 -0
- hyperflask/static/app.js +5 -0
- hyperflask/static/htmx.js +5 -0
- hyperflask/static/reactive.js +5 -0
- hyperflask/static/stimulus.js +4 -0
- hyperflask/static/translations.js +55 -0
- hyperflask/ui/components/README.md +3 -0
- hyperflask/ui/components/actions/Button.html +6 -0
- hyperflask/ui/components/actions/Dropdown.html +16 -0
- hyperflask/ui/components/actions/DropdownItem.html +8 -0
- hyperflask/ui/components/actions/LinkButton.html +6 -0
- hyperflask/ui/components/actions/Modal.html +28 -0
- hyperflask/ui/components/actions/ModalAction.html +12 -0
- hyperflask/ui/components/actions/ModalTitle.html +6 -0
- hyperflask/ui/components/data-display/Accordion.html +8 -0
- hyperflask/ui/components/data-display/AccordionPanel.html +18 -0
- hyperflask/ui/components/data-input/Checkbox.html +4 -0
- hyperflask/ui/components/data-input/FileInput.html +4 -0
- hyperflask/ui/components/data-input/FormControl.html +15 -0
- hyperflask/ui/components/data-input/Input.html +4 -0
- hyperflask/ui/components/data-input/LabeledCheckbox.html +11 -0
- hyperflask/ui/components/data-input/LabeledTextInput.html +11 -0
- hyperflask/ui/components/data-input/Radiobox.html +4 -0
- hyperflask/ui/components/data-input/Range.html +4 -0
- hyperflask/ui/components/data-input/Rating.html +20 -0
- hyperflask/ui/components/data-input/Select.html +11 -0
- hyperflask/ui/components/data-input/Textarea.html +6 -0
- hyperflask/ui/components/data-input/Toggle.html +4 -0
- hyperflask/ui/components/feedback/Loading.html +4 -0
- hyperflask/ui/components/feedback/Toast.html +8 -0
- hyperflask/ui/components/layout/Footer.html +8 -0
- hyperflask/ui/components/layout/FooterLink.html +6 -0
- hyperflask/ui/components/layout/FooterNav.html +11 -0
- hyperflask/ui/components/layout/Indicator.html +11 -0
- hyperflask/ui/components/navigation/Link.html +6 -0
- hyperflask/ui/components/navigation/Navbar.html +21 -0
- hyperflask/ui/forms.html +16 -0
- hyperflask/ui/icons.html +5 -0
- hyperflask/ui/modal.html +7 -0
- hyperflask/utils/__init__.py +5 -0
- hyperflask/utils/htmx.py +24 -0
- hyperflask/utils/page_actions.py +25 -0
- hyperflask/utils/request.py +25 -0
- hyperflask/utils/tokens.py +28 -0
- hyperflask-0.1.0.dist-info/LICENSE +19 -0
- hyperflask-0.1.0.dist-info/METADATA +157 -0
- hyperflask-0.1.0.dist-info/RECORD +66 -0
- hyperflask-0.1.0.dist-info/WHEEL +4 -0
- hyperflask-0.1.0.dist-info/entry_points.txt +3 -0
hyperflask/factory.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from .app import Hyperflask, db
|
|
2
|
+
from .resources import Resource
|
|
3
|
+
from .api import Api
|
|
4
|
+
from flask_configurator import Config
|
|
5
|
+
import importlib
|
|
6
|
+
from importlib.abc import MetaPathFinder, SourceLoader
|
|
7
|
+
from importlib.util import spec_from_file_location
|
|
8
|
+
from importlib.machinery import SourceFileLoader
|
|
9
|
+
from importlib.metadata import entry_points
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import runpy
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
app = None
|
|
17
|
+
api = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_config(root_path=None):
|
|
21
|
+
debug = os.environ.get("FLASK_DEBUG") == "1"
|
|
22
|
+
default_env = "development" if debug else "production"
|
|
23
|
+
config = Config(root_path, defaults={
|
|
24
|
+
"SQLORM_URI": "sqlite://app.db"
|
|
25
|
+
})
|
|
26
|
+
config.load("config.yml", default_env)
|
|
27
|
+
if config["ENV"] == "development" and not config.get("SECRET_KEY"):
|
|
28
|
+
logging.getLogger().warn("Missing SECRET_KEY has been automatically set to 'changeme' while in development mode")
|
|
29
|
+
config["SECRET_KEY"] = "changeme"
|
|
30
|
+
return config
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_app(root_path=None):
|
|
34
|
+
global app
|
|
35
|
+
global api
|
|
36
|
+
config = load_config(root_path)
|
|
37
|
+
static_folder = config.get("STATIC_FOLDER", "public")
|
|
38
|
+
migrations_folder = config.get("MIGRATIONS_FOLDER", "migrations")
|
|
39
|
+
|
|
40
|
+
if root_path is None:
|
|
41
|
+
root_path = os.getcwd()
|
|
42
|
+
|
|
43
|
+
if os.path.exists(os.path.join(root_path, "app")):
|
|
44
|
+
static_folder = os.path.abspath(os.path.join(root_path, static_folder))
|
|
45
|
+
migrations_folder = os.path.abspath(os.path.join(root_path, migrations_folder))
|
|
46
|
+
import_name = "app"
|
|
47
|
+
root_path = os.path.join(root_path, "app")
|
|
48
|
+
sys.meta_path.insert(0, VirtualPackageFinder(import_name, root_path))
|
|
49
|
+
else:
|
|
50
|
+
import_name = "__main__"
|
|
51
|
+
|
|
52
|
+
app = Hyperflask(import_name, root_path=root_path, config=config, static_folder=static_folder,
|
|
53
|
+
static_url_path=config.get("STATIC_URL_PATH", "/static"),
|
|
54
|
+
migrations_folder=migrations_folder)
|
|
55
|
+
|
|
56
|
+
api = Api(app)
|
|
57
|
+
|
|
58
|
+
if app.config.get("LOAD_HYPERFLASK_EXTENSIONS", True):
|
|
59
|
+
for entry in entry_points(group="hyperflask.extensions"):
|
|
60
|
+
entry.load()(app)
|
|
61
|
+
|
|
62
|
+
extensions = {}
|
|
63
|
+
if "FLASK_EXTENSIONS" in app.config:
|
|
64
|
+
extensions.update(dict([ext.items()[0] if isinstance(ext, dict) else (ext, {}) for ext in app.config["FLASK_EXTENSIONS"]]))
|
|
65
|
+
|
|
66
|
+
for ext, config in extensions.items():
|
|
67
|
+
module, class_name = ext.rsplit(":", 1)
|
|
68
|
+
m = importlib.import_module(module)
|
|
69
|
+
getattr(m, class_name)(app, **config)
|
|
70
|
+
|
|
71
|
+
try_import(app, "models")
|
|
72
|
+
try_import(app, "routes")
|
|
73
|
+
try_import(app, "api")
|
|
74
|
+
try_import(app, "tasks")
|
|
75
|
+
try_import(app, "cron")
|
|
76
|
+
|
|
77
|
+
resources = try_import(app, "resources")
|
|
78
|
+
if resources:
|
|
79
|
+
for name in dir(resources):
|
|
80
|
+
obj = getattr(resources, name)
|
|
81
|
+
if issubclass(obj, Resource) and obj.url_prefix:
|
|
82
|
+
obj.register(app, api)
|
|
83
|
+
|
|
84
|
+
db.init_db()
|
|
85
|
+
return app
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def try_import(app, name, import_submodules=True):
|
|
89
|
+
if app.import_name != "__main__":
|
|
90
|
+
name = f"{app.import_name}.{name}"
|
|
91
|
+
try:
|
|
92
|
+
mod = importlib.import_module(name)
|
|
93
|
+
|
|
94
|
+
if import_submodules:
|
|
95
|
+
is_virtual_package = os.path.isdir(os.path.join(app.root_path, name)) and not os.path.exists(os.path.join(app.root_path, name, "__init__.py"))
|
|
96
|
+
if is_virtual_package:
|
|
97
|
+
mod = {}
|
|
98
|
+
for f in os.listdir(os.path.join(app.root_path, name)):
|
|
99
|
+
if f.endswith(".py"):
|
|
100
|
+
m = importlib.import_module(f"{name}.{f.rsplit('.', 1)[0]}")
|
|
101
|
+
mod.update({k: getattr(m, k) for k in dir(m) if not k.startswith("_")})
|
|
102
|
+
return mod
|
|
103
|
+
|
|
104
|
+
return {k: getattr(mod, k) for k in dir(mod) if not k.startswith("_")}
|
|
105
|
+
except ImportError:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_factory_package(name):
|
|
110
|
+
tasks_dir = os.path.join(app.root_path, name)
|
|
111
|
+
if not os.path.exists(os.path.join(app.root_path, f"{name}.py")) and os.path.isdir(tasks_dir) and not os.path.exists(os.path.join(tasks_dir, "__init__.py")):
|
|
112
|
+
return tasks_dir
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def create_task_from_file(app, filename, **kwargs):
|
|
117
|
+
kwargs["actor_name"] = os.path.basename(filename).rsplit(".", 1)[0]
|
|
118
|
+
@app.actor(**kwargs)
|
|
119
|
+
def actor():
|
|
120
|
+
runpy.run_path(filename)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def extract_cron_schedule_from_file(filename):
|
|
124
|
+
with open(filename) as f:
|
|
125
|
+
line = f.readline()
|
|
126
|
+
if not line.startswith("#"):
|
|
127
|
+
raise Exception("Cronjobs must start with a comment line specifying the schedule")
|
|
128
|
+
return line[1:].strip()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class VirtualPackageFinder(MetaPathFinder):
|
|
132
|
+
def __init__(self, package_name, path=None):
|
|
133
|
+
if not path:
|
|
134
|
+
path = os.path.join(os.getcwd(), package_name.replace(".", "/"))
|
|
135
|
+
self.package_name = package_name
|
|
136
|
+
self.path = path
|
|
137
|
+
|
|
138
|
+
def find_spec(self, fullname, path, target=None):
|
|
139
|
+
if not fullname.startswith(self.package_name):
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
if fullname == self.package_name:
|
|
143
|
+
relname = ""
|
|
144
|
+
else:
|
|
145
|
+
relname = fullname[len(self.package_name)+1:].replace(".", "/")
|
|
146
|
+
|
|
147
|
+
if os.path.isdir(os.path.join(self.path, relname)):
|
|
148
|
+
filename = os.path.join(self.path, relname, "__init__.py")
|
|
149
|
+
loader_class = SourceFileLoader if os.path.exists(filename) else EmptyFileLoader
|
|
150
|
+
return spec_from_file_location(fullname, filename, loader=loader_class(fullname, filename),
|
|
151
|
+
submodule_search_locations=[os.path.join(self.path, relname)])
|
|
152
|
+
|
|
153
|
+
filename = os.path.join(self.path, f"{relname}.py")
|
|
154
|
+
if os.path.exists(filename):
|
|
155
|
+
return spec_from_file_location(fullname, filename, loader=SourceFileLoader(fullname, filename),
|
|
156
|
+
submodule_search_locations=None)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class EmptyFileLoader(SourceLoader):
|
|
161
|
+
def __init__(self, fullname, filename):
|
|
162
|
+
self.fullname = fullname
|
|
163
|
+
self.filename = filename
|
|
164
|
+
|
|
165
|
+
def get_filename(self, fullname):
|
|
166
|
+
return self.filename
|
|
167
|
+
|
|
168
|
+
def get_data(self, filename):
|
|
169
|
+
return ""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
create_app()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from wtforms import validators, ValidationError
|
|
2
|
+
from wtforms.fields import *
|
|
3
|
+
from wtforms.csrf.core import CSRFTokenField
|
|
4
|
+
from flask_wtf import FlaskForm
|
|
5
|
+
from flask import abort
|
|
6
|
+
from jinja_wtforms.form import TEMPLATE_FORM_FIELDS
|
|
7
|
+
from .fields import *
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
TEMPLATE_FORM_FIELDS.update({
|
|
11
|
+
"checkbox": BooleanField,
|
|
12
|
+
"decimal": DecimalField,
|
|
13
|
+
"date": DateField,
|
|
14
|
+
"datetime": DateTimeField,
|
|
15
|
+
"float": FloatField,
|
|
16
|
+
"int": IntegerField,
|
|
17
|
+
"radio": RadioField,
|
|
18
|
+
"select": SelectField,
|
|
19
|
+
"selectmulti": SelectMultipleField,
|
|
20
|
+
"text": StringField,
|
|
21
|
+
"textarea": TextAreaField,
|
|
22
|
+
"password": PasswordField,
|
|
23
|
+
"hidden": HiddenField,
|
|
24
|
+
"datetimelocal": DateTimeLocalField,
|
|
25
|
+
"decimalrange": DecimalRangeField,
|
|
26
|
+
"email": EmailField,
|
|
27
|
+
"intrange": IntegerRangeField,
|
|
28
|
+
"search": SearchField,
|
|
29
|
+
"tel": TelField,
|
|
30
|
+
"url": URLField,
|
|
31
|
+
"file": FileField,
|
|
32
|
+
"files": MultipleFileField
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Form(FlaskForm):
|
|
37
|
+
def validate_or_400(self):
|
|
38
|
+
if not self.validate():
|
|
39
|
+
abort(400)
|
|
40
|
+
|
|
41
|
+
def populate_obj(self, obj):
|
|
42
|
+
for name, field in self._fields.items():
|
|
43
|
+
if not isinstance(field, CSRFTokenField):
|
|
44
|
+
field.populate_obj(obj, name)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def data(self):
|
|
48
|
+
return {name: f.data for name, f in self._fields.items() if not isinstance(f, CSRFTokenField)}
|
|
49
|
+
|
|
50
|
+
def data_without(self, *fields):
|
|
51
|
+
return {name: f.data for name, f in self._fields.items() if name not in fields and not isinstance(f, CSRFTokenField)}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from . import widgets
|
|
2
|
+
from wtforms import fields
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
__all__ = ('StringField', 'PasswordField', 'EmailField')
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StringField(fields.StringField):
|
|
9
|
+
widget = widgets.TextInput()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PasswordField(fields.PasswordField):
|
|
13
|
+
widget = widgets.PasswordInput()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EmailField(fields.StringField):
|
|
17
|
+
widget = widgets.EmailInput()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from wtforms import widgets
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def create_input_class(basecls):
|
|
5
|
+
class Input(basecls):
|
|
6
|
+
def __call__(self, field, **kwargs):
|
|
7
|
+
extend_class(kwargs, "input input-bordered w-full")
|
|
8
|
+
return super().__call__(field, **kwargs)
|
|
9
|
+
return Input
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
TextInput = create_input_class(widgets.TextInput)
|
|
13
|
+
PasswordInput = create_input_class(widgets.PasswordInput)
|
|
14
|
+
EmailInput = create_input_class(widgets.EmailInput)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extend_class(kwargs, classes):
|
|
18
|
+
c = kwargs.pop('class', '') or kwargs.pop('class_', '')
|
|
19
|
+
kwargs['class'] = '%s %s' % (classes, c)
|
hyperflask/layout.html
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="{{config.get("SITE_LANG", "en")}}" {% block html_tag_attrs %}{% endblock %}>
|
|
3
|
+
<head>
|
|
4
|
+
{% block head -%}
|
|
5
|
+
{% block head_metas %}
|
|
6
|
+
<meta charset="utf-8">
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
8
|
+
{% endblock %}
|
|
9
|
+
{% if config["PWA"] -%}
|
|
10
|
+
<link rel="manifest" href="{{url_for("pwa_manifest")}}" />
|
|
11
|
+
{%- endif %}
|
|
12
|
+
{% block page_title %}<title>{% if page_title is defined %}{{ page_title }}{% if config.get("SITE_TITLE") %} | {% endif %}{% endif %}{% if config.get("SITE_TITLE") %}{{config["SITE_TITLE"]}}{% endif %}</title>{% endblock %}
|
|
13
|
+
{% asset_tags %}
|
|
14
|
+
{% block head_assets %}{% endblock %}
|
|
15
|
+
{%- endblock %}
|
|
16
|
+
</head>
|
|
17
|
+
{% block body_tag -%}
|
|
18
|
+
<body
|
|
19
|
+
{% if config["HTMX_BOOST_SITE"] %}hx-boost="true"{% endif %}
|
|
20
|
+
{% if config["PWA"] %}hx-ext="pwa" pwa-service-worker="{{url_for('pwa_service_worker')}}"{% endif %}
|
|
21
|
+
{% block body_tag_attrs %}{% endblock %}
|
|
22
|
+
>
|
|
23
|
+
{%- endblock %}
|
|
24
|
+
{% block body %}{% block content %}{% endblock %}{% endblock %}
|
|
25
|
+
{% block footer %}{% endblock %}
|
|
26
|
+
{% block body_end %}{% endblock %}
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from flask_file_routes import DEFAULT_HELPERS
|
|
2
|
+
from flask import g, current_app
|
|
3
|
+
from .utils.page_actions import page_action
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def csrf_protect(page):
|
|
7
|
+
current_app.csrf.protect()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def forms(page):
|
|
11
|
+
return current_app.forms[page.template]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def form(page):
|
|
15
|
+
def form_helper(template=None, **kwargs):
|
|
16
|
+
page.form = current_app.forms[template or page.template](**kwargs)
|
|
17
|
+
return page.form
|
|
18
|
+
return form_helper
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def stream(page):
|
|
22
|
+
g.__page_stream__ = True
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def action(page):
|
|
27
|
+
def decorator(func=None, name=None):
|
|
28
|
+
return page_action(func, name)
|
|
29
|
+
return decorator
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
DEFAULT_HELPERS.update(csrf_protect=csrf_protect,
|
|
33
|
+
forms=forms,
|
|
34
|
+
form=form,
|
|
35
|
+
stream=stream,
|
|
36
|
+
action=action)
|
hyperflask/reactivity.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from jinja2.ext import Extension
|
|
2
|
+
from jinja_super_macros import html_tag
|
|
3
|
+
from flask import current_app
|
|
4
|
+
from .resources import ResourceList, Resource
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def reactive(obj, attr=None):
|
|
8
|
+
if attr and hasattr(getattr(obj, attr), "__reactive__"):
|
|
9
|
+
obj = getattr(obj, attr)
|
|
10
|
+
attr = None
|
|
11
|
+
elif not hasattr(obj, "__reactive__"):
|
|
12
|
+
return getattr(obj, attr) if attr else obj
|
|
13
|
+
|
|
14
|
+
current_app.assets.include("@hyperflask/reactive")
|
|
15
|
+
|
|
16
|
+
mercure_topic, many = obj.__reactive__()
|
|
17
|
+
|
|
18
|
+
if many:
|
|
19
|
+
if attr:
|
|
20
|
+
raise Exception("Cannot access attribute of a ResourceList")
|
|
21
|
+
return html_tag("div", {"x-reactive-list": obj.resource.name})
|
|
22
|
+
|
|
23
|
+
return html_tag("span", {"x-reactive-prop": obj.resource.name, "data-attr": attr})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ReactiveExtension(Extension):
|
|
27
|
+
def __init__(self, environment):
|
|
28
|
+
super().__init__(environment)
|
|
29
|
+
environment.globals["reactive"] = reactive
|
|
30
|
+
|
|
31
|
+
def preprocess(self, source, name, filename=None):
|
|
32
|
+
pos = source.find("{!")
|
|
33
|
+
while pos >= 0:
|
|
34
|
+
end = source.find("!}", pos)
|
|
35
|
+
if end < 0:
|
|
36
|
+
raise Exception("Missing closing bracket for reactive")
|
|
37
|
+
value = source[pos + 2:end]
|
|
38
|
+
if "." in value:
|
|
39
|
+
obj, attr = value.rsplit(".", 1)
|
|
40
|
+
args = [obj, attr]
|
|
41
|
+
else:
|
|
42
|
+
args = [value]
|
|
43
|
+
replacement = "{{ reactive(" + ", ".join(args) + ") }}"
|
|
44
|
+
source = source[:pos] + replacement + source[end + 2:]
|
|
45
|
+
pos = source.find("{!", pos + len(replacement) + 2)
|
|
46
|
+
return source
|
hyperflask/resources.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from hyperflask import db
|
|
2
|
+
from sqlorm import Model
|
|
3
|
+
from flask_apispec import marshal_with, use_args
|
|
4
|
+
from flask_apispec.views import MethodResource
|
|
5
|
+
from werkzeug.exceptions import HTTPException
|
|
6
|
+
import typing as t
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ResourceMeta(type):
|
|
10
|
+
def __new__(cls, name, bases, attrs):
|
|
11
|
+
cls = super().__new__(cls, name, bases, attrs)
|
|
12
|
+
if not bases:
|
|
13
|
+
return cls
|
|
14
|
+
|
|
15
|
+
if not hasattr(cls, "schema"):
|
|
16
|
+
cls.schema = create_model_schema(cls.model)
|
|
17
|
+
|
|
18
|
+
return cls
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_model_schema(model):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ResourceError(HTTPException):
|
|
26
|
+
def __init__(self, description, code=400):
|
|
27
|
+
super().__init__(description)
|
|
28
|
+
self.code = code
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ResourceNotFound(ResourceError):
|
|
32
|
+
def __init__(self):
|
|
33
|
+
super().__init__("URL not found", 404)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Resource(metaclass=ResourceMeta):
|
|
37
|
+
model: t.ClassVar[Model]
|
|
38
|
+
url_prefix = None
|
|
39
|
+
object_url_prefix = "/<id>"
|
|
40
|
+
expose_create = True
|
|
41
|
+
expose_edit = True
|
|
42
|
+
expose_methods = t.ClassVar[t.Sequence[str]]
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def list(cls):
|
|
46
|
+
return ResourceList(cls, cls.model.find_all())
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def create(cls, **kwargs):
|
|
50
|
+
with db:
|
|
51
|
+
return cls(cls.model.create(**kwargs))
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def get(cls, id):
|
|
55
|
+
obj = cls.model.get(id)
|
|
56
|
+
if not obj:
|
|
57
|
+
raise ResourceNotFound()
|
|
58
|
+
return cls(obj)
|
|
59
|
+
|
|
60
|
+
def __init__(self, obj):
|
|
61
|
+
self.obj = obj
|
|
62
|
+
|
|
63
|
+
def update(self, data):
|
|
64
|
+
with db:
|
|
65
|
+
self.obj.update(**data)
|
|
66
|
+
|
|
67
|
+
def delete(self):
|
|
68
|
+
with db:
|
|
69
|
+
self.obj.delete()
|
|
70
|
+
|
|
71
|
+
def __getattr__(self, name):
|
|
72
|
+
return getattr(self.obj, name)
|
|
73
|
+
|
|
74
|
+
def __reactive__(self):
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def register(cls, app, url_prefix="", endpoint_prefix=""):
|
|
79
|
+
from .api import create_view_func
|
|
80
|
+
|
|
81
|
+
@marshal_with(cls.schema(many=True))
|
|
82
|
+
def many_get():
|
|
83
|
+
return cls.list()
|
|
84
|
+
|
|
85
|
+
@use_args(cls.schema)
|
|
86
|
+
@marshal_with(cls.schema)
|
|
87
|
+
def many_post(data):
|
|
88
|
+
return cls.create(**data)
|
|
89
|
+
|
|
90
|
+
@marshal_with(cls.schema)
|
|
91
|
+
def single_get(id):
|
|
92
|
+
return cls.get(id)
|
|
93
|
+
|
|
94
|
+
@use_args(cls.schema)
|
|
95
|
+
@marshal_with(cls.schema)
|
|
96
|
+
def single_put(data, id):
|
|
97
|
+
obj = cls.get(id)
|
|
98
|
+
obj.update(data)
|
|
99
|
+
return obj
|
|
100
|
+
|
|
101
|
+
def single_delete(id):
|
|
102
|
+
obj = cls.get(id)
|
|
103
|
+
obj.delete()
|
|
104
|
+
return {}, 204
|
|
105
|
+
|
|
106
|
+
many_methods = {"get": many_get}
|
|
107
|
+
if cls.expose_create:
|
|
108
|
+
many_methods["post"] = many_post
|
|
109
|
+
many_view = type(f"{cls.__name__}ManyView", (MethodResource,), many_methods)
|
|
110
|
+
many_endpoint = endpoint_prefix + cls.__name__.lower() + "_many"
|
|
111
|
+
|
|
112
|
+
single_methods = {"get": single_get}
|
|
113
|
+
if cls.expose_edit:
|
|
114
|
+
single_methods.update({"put": single_put, "delete": single_delete})
|
|
115
|
+
single_view = type(f"{cls.__name__}View", (MethodResource,), single_methods)
|
|
116
|
+
single_endpoint = endpoint_prefix + cls.__name__.lower()
|
|
117
|
+
|
|
118
|
+
app.add_url_rule(url_prefix + cls.url_prefix, view_func=many_view.as_view(many_endpoint))
|
|
119
|
+
app.add_url_rule(url_prefix + cls.url_prefix + cls.object_url_prefix, view_func=single_view.as_view(single_endpoint))
|
|
120
|
+
|
|
121
|
+
endpoints = [many_endpoint, single_endpoint]
|
|
122
|
+
|
|
123
|
+
for attr in dir(cls):
|
|
124
|
+
if hasattr(getattr(cls, attr), "__expose__"):
|
|
125
|
+
rule, endpoint, kwargs = getattr(cls, attr).__expose__
|
|
126
|
+
view_func = create_view_func(getattr(cls, attr))
|
|
127
|
+
app.add_url_rule(url_prefix + cls.url_prefix + rule, endpoint_prefix + endpoint, view_func=view_func, **kwargs)
|
|
128
|
+
endpoints.append(endpoint_prefix + endpoint)
|
|
129
|
+
|
|
130
|
+
return endpoints
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def expose(rule=None, endpoint=None, **kwargs):
|
|
134
|
+
def decorator(func):
|
|
135
|
+
func.__expose__ = (rule or func.__name__, endpoint or func.__name__, kwargs)
|
|
136
|
+
return func
|
|
137
|
+
return decorator
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ResourceList:
|
|
141
|
+
def __init__(self, resource_class, objects):
|
|
142
|
+
self.resource_class = resource_class
|
|
143
|
+
self.objects = objects
|
|
144
|
+
|
|
145
|
+
def __iter__(self):
|
|
146
|
+
return [self.resource_class(obj) for obj in self.objects]
|
|
147
|
+
|
|
148
|
+
def __reactive__(self):
|
|
149
|
+
return
|
hyperflask/static/app.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
export function _(text, ...args) {
|
|
4
|
+
return formatTranslationString(getCatalogValue(text)[1] || text, ...args)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function _p(context, text, ...args) {
|
|
8
|
+
return formatTranslationString(getCatalogValue(text, context)[1] || text, ...args)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function _n(singular, plural, count, args, context) {
|
|
12
|
+
args = args || {};
|
|
13
|
+
args['count'] = count;
|
|
14
|
+
const value = getCatalogValue(singular, context);
|
|
15
|
+
if (value) {
|
|
16
|
+
if (value[1]) {
|
|
17
|
+
singular = value[1];
|
|
18
|
+
}
|
|
19
|
+
if (value[2]) {
|
|
20
|
+
plural = value[2];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (count > 1) {
|
|
24
|
+
return formatTranslationString(plural, args);
|
|
25
|
+
}
|
|
26
|
+
return formatTranslationString(singular, args);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function _np(context, singular, plural, count, args) {
|
|
30
|
+
return _n(singular, plural, count, args, context);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatTranslationString(text, ...data) {
|
|
34
|
+
if (data.length) {
|
|
35
|
+
if (data.length === 1 && $.isPlainObject(data[0])) {
|
|
36
|
+
data = data[0];
|
|
37
|
+
}
|
|
38
|
+
for (let [key, value] of Object.entries(data)) {
|
|
39
|
+
text = text.replace(new RegExp('\\{' + key + '\\}', 'gi'), value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return text;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getCatalogValue(text, context) {
|
|
46
|
+
const messageId = context ? context + '::' + text : text;
|
|
47
|
+
if (typeof(catalog[messageId]) !== 'undefined' && catalog[messageId][1]) {
|
|
48
|
+
return catalog[messageId];
|
|
49
|
+
}
|
|
50
|
+
return [null, null];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getCurrentLocale() {
|
|
54
|
+
|
|
55
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{% macro Button(style=None, active=None, outline=None, size=None, wide=None, glass=None) -%}
|
|
2
|
+
<button class="btn {{kwargs.pop('class', '')}} {% if style %} btn-{{style}}{% endif %} {% if active %} btn-active{% endif %} {% if outline %} btn-outline{% endif %} {% if size %} btn-{{size}}{% endif %} {% if wide %} btn-wide{% endif %} {% if glass %} glass{% endif %}" {{ html_attrs(kwargs) }}>
|
|
3
|
+
{{ caller() }}
|
|
4
|
+
|
|
5
|
+
</button>
|
|
6
|
+
{%- endmacro %}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{% macro Dropdown() -%}
|
|
2
|
+
<div class="dropdown">
|
|
3
|
+
|
|
4
|
+
<div tabindex="0" role="button" class="btn m-1">
|
|
5
|
+
Click
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
|
|
9
|
+
|
|
10
|
+
{{ caller() }}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
</ul>
|
|
14
|
+
|
|
15
|
+
</div>
|
|
16
|
+
{%- endmacro %}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{% macro LinkButton(style=None, active=None, outline=None, size=None, wide=None, glass=None) -%}
|
|
2
|
+
<a class="btn {{kwargs.pop('class', '')}} {% if style %} btn-{{style}}{% endif %} {% if active %} btn-active{% endif %} {% if outline %} btn-outline{% endif %} {% if size %} btn-{{size}}{% endif %} {% if wide %} btn-wide{% endif %} {% if glass %} glass{% endif %}" {{ html_attrs(kwargs) }}>
|
|
3
|
+
{{ caller() }}
|
|
4
|
+
|
|
5
|
+
</a>
|
|
6
|
+
{%- endmacro %}
|