diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e13a5c..57295c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to wuttaweb will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.26.0 (2025-12-28) + +### Feat + +- add "wizard" for creating new table/model/revision +- add support for Create Alembic Migration +- add CopyableTextWidget and `` component +- overhaul how form vue template is rendered +- add basic views for Alembic Migrations, Dashboard +- add basic Table views + +### Fix + +- let checkbox widget show static text instead of Yes/No +- rename form-saving methods etc. for consistency in MasterView +- temporarily avoid make_uuid() +- remove password filter option for Users grid +- use smarter default for `grid.sort_multiple` based on model class + ## v0.25.1 (2025-12-20) ### Fix diff --git a/docs/api/wuttaweb.views.alembic.rst b/docs/api/wuttaweb.views.alembic.rst new file mode 100644 index 0000000..ce82a7a --- /dev/null +++ b/docs/api/wuttaweb.views.alembic.rst @@ -0,0 +1,6 @@ + +``wuttaweb.views.alembic`` +========================== + +.. automodule:: wuttaweb.views.alembic + :members: diff --git a/docs/api/wuttaweb.views.tables.rst b/docs/api/wuttaweb.views.tables.rst new file mode 100644 index 0000000..4594aaa --- /dev/null +++ b/docs/api/wuttaweb.views.tables.rst @@ -0,0 +1,6 @@ + +``wuttaweb.views.tables`` +========================= + +.. automodule:: wuttaweb.views.tables + :members: diff --git a/docs/conf.py b/docs/conf.py index 6bcd169..7465596 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -28,6 +28,7 @@ templates_path = ["_templates"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] intersphinx_mapping = { + "alembic": ("https://alembic.sqlalchemy.org/en/latest/", None), "colander": ("https://docs.pylonsproject.org/projects/colander/en/latest/", None), "deform": ("https://docs.pylonsproject.org/projects/deform/en/latest/", None), "fanstatic": ("https://www.fanstatic.org/en/latest/", None), diff --git a/docs/index.rst b/docs/index.rst index bd5c25a..f910cc9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -58,6 +58,7 @@ the narrative docs are pretty scant. That will eventually change. api/wuttaweb.subscribers api/wuttaweb.util api/wuttaweb.views + api/wuttaweb.views.alembic api/wuttaweb.views.auth api/wuttaweb.views.base api/wuttaweb.views.batch @@ -70,6 +71,7 @@ the narrative docs are pretty scant. That will eventually change. api/wuttaweb.views.reports api/wuttaweb.views.roles api/wuttaweb.views.settings + api/wuttaweb.views.tables api/wuttaweb.views.upgrades api/wuttaweb.views.users diff --git a/pyproject.toml b/pyproject.toml index 17e3c94..263fe66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.25.1" +version = "0.26.0" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] @@ -42,9 +42,10 @@ dependencies = [ "pyramid_fanstatic", "pyramid_mako", "pyramid_tm", + "SQLAlchemy-Utils", "waitress", "WebHelpers2", - "WuttJamaican[db]>=0.27.0", + "WuttJamaican[db]>=0.28.0", "zope.sqlalchemy>=1.5", ] diff --git a/src/wuttaweb/code-templates/new-table.mako b/src/wuttaweb/code-templates/new-table.mako new file mode 100644 index 0000000..046862f --- /dev/null +++ b/src/wuttaweb/code-templates/new-table.mako @@ -0,0 +1,68 @@ +## -*- coding: utf-8; mode: python; -*- +# -*- coding: utf-8; -*- +""" +Model definition for ${model_title_plural} +""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from wuttjamaican.db import model + + +class ${model_name}(model.Base): + """ + ${description} + """ + __tablename__ = "${table_name}" + % if any([c["data_type"]["type"] == "_fk_uuid_" for c in columns]): + __table_args__ = ( + % for column in columns: + % if column["data_type"]["type"] == "_fk_uuid_": + sa.ForeignKeyConstraint(["${column['name']}"], ["${column['data_type']['reference']}.uuid"], + name="${table_name}_fk_${column['data_type']['reference']}"), + % endif + % endfor + ) + % endif + % if versioned: + % if all([c["versioned"] for c in columns]): + __versioned__ = {} + % else: + __versioned__ = { + "exclude": [ + % for column in columns: + % if not column["versioned"]: + "${column['name']}", + % endif + % endfor + ], + } + % endif + % endif + __wutta_hint__ = { + "model_title": "${model_title}", + "model_title_plural": "${model_title_plural}", + } + % for column in columns: + + % if column["name"] == "uuid": + uuid = model.uuid_column() + % else: + ${column["name"]} = sa.Column(${column["formatted_data_type"]}, nullable=${column["nullable"]}, doc=""" + ${column["description"] or ""} + """) + % if column["data_type"]["type"] == "_fk_uuid_" and column["relationship"]: + ${column["relationship"]["name"]} = orm.relationship( + "${column['relationship']['reference_model']}", + doc=""" + ${column["description"] or ""} + """) + % endif + % endif + % endfor + + # TODO: you usually should define the __str__() method + + # def __str__(self): + # return self.name or "" diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index 6365aa8..0d2a42d 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -275,6 +275,10 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth deform_form = None validated = None + vue_template = "/forms/vue_template.mako" + fields_template = "/forms/vue_fields.mako" + buttons_template = "/forms/vue_buttons.mako" + def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals self, request, @@ -331,6 +335,7 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth self.show_button_cancel = show_button_cancel self.button_label_cancel = button_label_cancel self.auto_disable_cancel = auto_disable_cancel + self.form_attrs = {} self.config = self.request.wutta_config self.app = self.config.get_app() @@ -806,7 +811,10 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth # get fields fields = self.get_fields() if not fields: - raise NotImplementedError + raise ValueError( + "could not determine fields list; " + "please set model_class or fields explicitly" + ) if self.model_class: @@ -939,7 +947,7 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth """ return HTML.tag(self.vue_tagname, **kwargs) - def render_vue_template(self, template="/forms/vue_template.mako", **context): + def render_vue_template(self, template=None, **context): """ Render the Vue template block for the form. @@ -954,8 +962,8 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth @@ -969,36 +977,121 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth Actual output will of course depend on form attributes, i.e. :attr:`vue_tagname` and :attr:`fields` list etc. - :param template: Path to Mako template which is used to render - the output. + Default logic will also invoke (indirectly): + + * :meth:`render_vue_fields()` + * :meth:`render_vue_buttons()` + + :param template: Optional template path to override the class + default. + + :returns: HTML literal """ + context = self.get_vue_context(**context) + html = render(template or self.vue_template, context) + return HTML.literal(html) + + def get_vue_context(self, **context): # pylint: disable=missing-function-docstring context["form"] = self context["dform"] = self.get_deform() context.setdefault("request", self.request) context["model_data"] = self.get_vue_model_data() # set form method, enctype - context.setdefault("form_attrs", {}) - context["form_attrs"].setdefault("method", self.action_method) + form_attrs = context.setdefault("form_attrs", dict(self.form_attrs)) + form_attrs.setdefault("method", self.action_method) if self.action_method == "post": - context["form_attrs"].setdefault("enctype", "multipart/form-data") + form_attrs.setdefault("enctype", "multipart/form-data") # auto disable button on submit if self.auto_disable_submit: - context["form_attrs"]["@submit"] = "formSubmitting = true" + form_attrs["@submit"] = "formSubmitting = true" - output = render(template, context) - return HTML.literal(output) + # duplicate entire context for sake of fields/buttons template + context["form_context"] = context + + return context + + def render_vue_fields(self, context, template=None, **kwargs): + """ + Render the fields section within the form template. + + This is normally invoked from within the form's + ``vue_template`` like this: + + .. code-block:: none + + ${form.render_vue_fields(form_context)} + + There is a default ``fields_template`` but that is only the + last resort. Logic will first look for a + ``form_vue_fields()`` def within the *main template* being + rendered for the page. + + An example will surely help: + + .. code-block:: mako + + <%inherit file="/master/edit.mako" /> + + <%def name="form_vue_fields()"> + +

this is my custom fields section:

+ + ${form.render_vue_field("myfield")} + + + + This keeps the custom fields section within the main page + template as opposed to yet another file. But if your page + template has no ``form_vue_fields()`` def, then the class + default template is used. (Unless the ``template`` param + is specified.) + + See also :meth:`render_vue_template()` and + :meth:`render_vue_buttons()`. + + :param context: This must be the original context as provided + to the form's ``vue_template``. See example above. + + :param template: Optional template path to use instead of the + defaults described above. + + :returns: HTML literal + """ + context.update(kwargs) + html = False + + if not template: + + if main_template := context.get("main_template"): + try: + vue_fields = main_template.get_def("form_vue_fields") + except AttributeError: + pass + else: + html = vue_fields.render(**context) + + if html is False: + template = self.fields_template + + if html is False: + html = render(template, context) + + return HTML.literal(html) def render_vue_field( # pylint: disable=unused-argument,too-many-locals self, fieldname, readonly=None, + label=True, + horizontal=True, **kwargs, ): """ Render the given field completely, i.e. ```` wrapper - with label and containing a widget. + with label and a widget, with validation errors flagged as + needed. Actual output will depend on the field attributes etc. Typical output might look like: @@ -1009,14 +1102,23 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth horizontal type="is-danger" message="something went wrong!"> - + - .. warning:: + :param fieldname: Name of field to render. - Any ``**kwargs`` received from caller are ignored by this - method. For now they are allowed, for sake of backwawrd - compatibility. This may change in the future. + :param readonly: Optional override for readonly flag. + + :param label: Whether to include/set the field label. + + :param horizontal: Boolean value for the ``horizontal`` flag + on the field. + + :param \\**kwargs: Remaining kwargs are passed to widget's + ``serialize()`` method. + + :returns: HTML literal """ # readonly comes from: caller, field flag, or form flag if readonly is None: @@ -1034,10 +1136,9 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth # render proper widget if field is in deform/schema field = dform[fieldname] - kw = {} if readonly: - kw["readonly"] = True - html = field.serialize(**kw) + kwargs["readonly"] = True + html = field.serialize(**kwargs) else: # render static text if field not in deform/schema @@ -1052,12 +1153,13 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth html = HTML.literal(html or " ") # render field label - label = self.get_label(fieldname) + if label: + label = self.get_label(fieldname) # b-field attrs attrs = { - ":horizontal": "true", - "label": label, + ":horizontal": "true" if horizontal else "false", + "label": label or "", } # next we will build array of messages to display..some @@ -1085,6 +1187,36 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth return HTML.tag("b-field", c=[html], **attrs) + def render_vue_buttons(self, context, template=None, **kwargs): + """ + Render the buttons section within the form template. + + This is normally invoked from within the form's + ``vue_template`` like this: + + .. code-block:: none + + ${form.render_vue_buttons(form_context)} + + .. note:: + + This method does not yet inspect the main page template, + unlike :meth:`render_vue_fields()`. + + See also :meth:`render_vue_template()`. + + :param context: This must be the original context as provided + to the form's ``vue_template``. See example above. + + :param template: Optional template path to override the class + default. + + :returns: HTML literal + """ + context.update(kwargs) + html = render(template or self.buttons_template, context) + return HTML.literal(html) + def render_vue_finalize(self): """ Render the Vue "finalize" script for the form. @@ -1103,6 +1235,25 @@ class Form: # pylint: disable=too-many-instance-attributes,too-many-public-meth """ return render_vue_finalize(self.vue_tagname, self.vue_component) + def get_field_vmodel(self, field): + """ + Convenience to return the ``v-model`` data reference for the + given field. For instance: + + .. code-block:: none + + + +
+ easter egg! +
+ + :returns: JS-valid string referencing the field value + """ + dform = self.get_deform() + return f"modelData.{dform[field].oid}" + def get_vue_model_data(self): """ Returns a dict with form model data. Values may be nested diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py index 0ef6d87..72c4d79 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -60,7 +60,7 @@ from deform.widget import ( # pylint: disable=unused-import DateTimeInputWidget, MoneyInputWidget, ) -from webhelpers2.html import HTML +from webhelpers2.html import HTML, tags from wuttjamaican.conf import parse_list @@ -147,6 +147,24 @@ class NotesWidget(TextAreaWidget): readonly_template = "readonly/notes" +class CopyableTextWidget(Widget): # pylint: disable=abstract-method + """ + A readonly text widget which adds a "copy" icon/link just after + the text. + """ + + def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring + """ """ + if not cstruct: + return colander.null + + return HTML.tag("wutta-copyable-text", **{"text": cstruct}) + + def deserialize(self, field, pstruct): # pylint: disable=empty-docstring + """ """ + raise NotImplementedError + + class WuttaCheckboxChoiceWidget(CheckboxChoiceWidget): """ Custom widget for :class:`python:set` fields. @@ -537,3 +555,58 @@ class BatchIdWidget(Widget): # pylint: disable=abstract-method batch_id = int(cstruct) return f"{batch_id:08d}" + + +class AlembicRevisionWidget(Widget): # pylint: disable=missing-class-docstring + """ + Widget to show an Alembic revision identifier, with link to view + the revision. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + + def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring + """ """ + if not cstruct: + return colander.null + + return tags.link_to( + cstruct, self.request.route_url("alembic.migrations.view", revision=cstruct) + ) + + def deserialize(self, field, pstruct): # pylint: disable=empty-docstring + """ """ + raise NotImplementedError + + +class AlembicRevisionsWidget(Widget): + """ + Widget to show list of Alembic revision identifiers, with links to + view each revision. + """ + + def __init__(self, request, *args, **kwargs): + super().__init__(*args, **kwargs) + self.request = request + self.config = self.request.wutta_config + + def serialize(self, field, cstruct, **kw): # pylint: disable=empty-docstring + """ """ + if not cstruct: + return colander.null + + revisions = [] + for rev in self.config.parse_list(cstruct): + revisions.append( + tags.link_to( + rev, self.request.route_url("alembic.migrations.view", revision=rev) + ) + ) + + return ", ".join(revisions) + + def deserialize(self, field, pstruct): # pylint: disable=empty-docstring + """ """ + raise NotImplementedError diff --git a/src/wuttaweb/templates/alembic/dashboard.mako b/src/wuttaweb/templates/alembic/dashboard.mako new file mode 100644 index 0000000..badacc7 --- /dev/null +++ b/src/wuttaweb/templates/alembic/dashboard.mako @@ -0,0 +1,234 @@ +## -*- coding: utf-8; -*- +<%inherit file="/page.mako" /> + +<%def name="page_content()"> +
+ +
+
+ + + {{ script.dir }} + + + + {{ script.env_py_location }} + + + +
    +
  • + {{ path }} +
  • +
+
+ +
+ +
+
+ % if request.has_perm("app_tables.list"): + + % endif + % if request.has_perm("app_tables.create"): + + % endif +
+
+ % if request.has_perm("alembic.migrations.list"): + + % endif + % if request.has_perm("alembic.migrations.create"): + + % endif +
+ % if request.has_perm("alembic.migrate"): +
+ + Migrate Database + +
+ % endif +
+
+ +
+

Script Heads

+ + + + {{ props.row.branch_labels }} + + + {{ props.row.doc }} + + + {{ props.row.is_current ? "Yes" : "No" }} + + + + + + + + + {{ props.row.path }} + + + +
+

Database Heads

+ + + + {{ props.row.branch_labels }} + + + {{ props.row.doc }} + + + + + + + + + {{ props.row.path }} + + + + % if request.has_perm("alembic.migrate"): + <${b}-modal has-modal-card + :active.sync="migrateShowDialog"> + + + % endif + + + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + + diff --git a/src/wuttaweb/templates/alembic/migrations/configure.mako b/src/wuttaweb/templates/alembic/migrations/configure.mako new file mode 100644 index 0000000..903a85d --- /dev/null +++ b/src/wuttaweb/templates/alembic/migrations/configure.mako @@ -0,0 +1,31 @@ +## -*- coding: utf-8; -*- +<%inherit file="/configure.mako" /> + +<%def name="form_content()"> + +

Basics

+
+ + + + + + + + +
+ + +<%def name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + + diff --git a/src/wuttaweb/templates/alembic/migrations/create.mako b/src/wuttaweb/templates/alembic/migrations/create.mako new file mode 100644 index 0000000..af3803e --- /dev/null +++ b/src/wuttaweb/templates/alembic/migrations/create.mako @@ -0,0 +1,70 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/create.mako" /> + +<%def name="page_content()"> +
+
+ % if request.has_perm("alembic.dashboard"): + + % endif +
+ + ${parent.page_content()} + + +<%def name="form_vue_fields()"> + + ${form.render_vue_field("description", horizontal=False)} + + ${form.render_vue_field("autogenerate", horizontal=False, label=False, static_text="Auto-generate migration logic based on current app model")} + +
+ + +
+ +
+ + Revise existing branch + +
+ +
+ + ${form.render_vue_field("revise_branch", horizontal=True)} + +
+ +
+ + Start new branch + +
+ +
+ + ${form.render_vue_field("new_branch", horizontal=True)} + ${form.render_vue_field("version_location", horizontal=True)} + +

+ NOTE: New version locations must be added to the + [alembic] section of + your config file (and app restarted) before they will appear as + options here. +

+ +
+
+ +
+ diff --git a/src/wuttaweb/templates/alembic/migrations/index.mako b/src/wuttaweb/templates/alembic/migrations/index.mako new file mode 100644 index 0000000..bcda890 --- /dev/null +++ b/src/wuttaweb/templates/alembic/migrations/index.mako @@ -0,0 +1,25 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/index.mako" /> + +<%def name="page_content()"> +
+ + % if request.has_perm("alembic.dashboard"): + + % endif + + % if request.has_perm("app_tables.list"): + + % endif + +
+ ${parent.page_content()} + diff --git a/src/wuttaweb/templates/alembic/migrations/view.mako b/src/wuttaweb/templates/alembic/migrations/view.mako new file mode 100644 index 0000000..6bd47e8 --- /dev/null +++ b/src/wuttaweb/templates/alembic/migrations/view.mako @@ -0,0 +1,17 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/view.mako" /> + +<%def name="page_content()"> +
+
+ % if request.has_perm("alembic.dashboard"): + + % endif +
+ + ${parent.page_content()} + diff --git a/src/wuttaweb/templates/appinfo/index.mako b/src/wuttaweb/templates/appinfo/index.mako index 7f1abb5..137d8b1 100644 --- a/src/wuttaweb/templates/appinfo/index.mako +++ b/src/wuttaweb/templates/appinfo/index.mako @@ -32,6 +32,26 @@ +
+ + % if request.has_perm("app_tables.list"): + + % endif + + % if request.has_perm("alembic.dashboard"): + + % endif + +
+