enferno-dev
Development skill for Enferno Flask framework. Use when implementing features, fixing bugs, or writing code for Enferno-based applications. This includes creating models, API endpoints, Vue.js fronten
enferno
dev
SKILL.mddata/enferno-dev
GitHubEnferno Development
Flask 3 + SQLAlchemy 2.x + Vue 3 + Vuetify 4. No frontend build step. uv for everything.
Quick Reference
uv run flask run --port 5001 # Dev server (5001 on macOS)
uv run flask create-db # Create tables and stamp migrations
uv run flask install # Create admin user
uv run flask db migrate -m "msg" # Draft migration, review before upgrade
uv run flask db upgrade # Apply migrations
uv run pytest # Routes, auth, admin API, migrations, setup.sh
uv run ruff check . && uv run ruff format . # Lint + format
Blueprint Structure
enferno/
├── feature_name/
│ ├── views.py # Page routes and /api/ endpoints
│ ├── models.py # SQLAlchemy models
│ └── forms.py # WTForms (if needed)
└── templates/feature_name/
Register in register_blueprints() in enferno/app.py:
from enferno.feature_name.views import bp as feature_bp
app.register_blueprint(feature_bp)
Models
Use BaseMixin (save()/delete()) and implement to_dict()/from_dict(). After changing models, run flask db migrate and review the generated revision.
from enferno.extensions import db
from enferno.utils.base import BaseMixin
class Product(db.Model, BaseMixin):
__tablename__ = "products"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False)
price = db.Column(db.Numeric(10, 2))
active = db.Column(db.Boolean, default=True)
def to_dict(self):
return {"id": self.id, "name": self.name, "price": float(self.price or 0), "active": self.active}
def from_dict(self, data):
self.name = data.get("name", self.name)
self.price = data.get("price", self.price)
self.active = data.get("active", self.active)
return self
API Endpoints
Protect the whole blueprint in before_request. Payloads are {"item": {...}}; lists return items, total, perPage. Log admin actions with Activity.register.
from flask import Blueprint, render_template, request
from flask_security import auth_required, current_user, roles_required
from enferno.extensions import db
from enferno.user.models import Activity
bp = Blueprint("products", __name__)
@bp.before_request
@auth_required("session")
@roles_required("admin")
def before_request():
pass
@bp.get("/products/")
def products_page():
return render_template("products/index.html")
@bp.get("/api/products")
def list_products():
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 25, type=int)
pagination = db.paginate(db.select(Product), page=page, per_page=per_page)
return {"items": [p.to_dict() for p in pagination.items], "total": pagination.total, "perPage": pagination.per_page}
@bp.post("/api/product/")
def create_product():
product = Product().from_dict(request.json.get("item", {}))
db.session.add(product)
db.session.commit()
Activity.register(current_user.id, "Product Create", product.to_dict())
return {"item": product.to_dict()}
@bp.post("/api/product/<int:id>")
def update_product(id):
product = db.get_or_404(Product, id)
old = product.to_dict()
product.from_dict(request.json.get("item", {}))
db.session.commit()
Activity.register(current_user.id, "Product Update", {"old": old, "new": product.to_dict()})
return {"item": product.to_dict()}
@bp.delete("/api/product/<int:id>")
def delete_product(id):
product = db.get_or_404(Product, id)
Activity.register(current_user.id, "Product Delete", product.to_dict())
db.session.delete(product)
db.session.commit()
return {"deleted": True}
Pages (Vue 3 + Vuetify)
layout.html owns <v-app>, the nav and the #app mount point. A page fills {% block content %} and mounts its app in {% block js %} with layoutMixin and registerEnfernoComponents, or the nav breaks. Vue expressions use ${ }; {{ }} is Jinja.
{% extends "layout.html" %}
{% block content %}
<v-card class="ma-2 mt-12 w-100">
<v-card-text>
<v-data-table-server :headers="headers" :items="items" :items-length="total"
:loading="loading" @update:options="loadItems">
<template v-slot:item.actions="{ item }">
<v-btn icon="ti-pencil" variant="text" aria-label="Edit" @click="editItem(item)"></v-btn>
</template>
</v-data-table-server>
</v-card-text>
</v-card>
{% endblock %}
{% block js %}
<script type="application/json" id="categories-data">{{ categories|tojson|safe }}</script>
<script>
const { createApp } = Vue;
const vuetify = Vuetify.createVuetify(config.vuetifyConfig);
const app = createApp({
mixins: [layoutMixin],
delimiters: config.delimiters,
data() {
return {
items: [], total: 0, loading: false,
categories: JSON.parse(document.querySelector('#categories-data').textContent),
headers: [
{ title: 'Name', key: 'name' },
{ title: 'Actions', key: 'actions', sortable: false },
],
};
},
methods: {
async loadItems({ page, itemsPerPage }) {
this.loading = true;
const res = await axios.get('/api/products', { params: { page, per_page: itemsPerPage } });
this.items = res.data.items;
this.total = res.data.total;
this.loading = false;
},
editItem(item) { /* open dialog */ },
},
});
registerEnfernoComponents(app);
app.use(vuetify).mount('#app');
</script>
{% endblock %}
Rules:
- Icons are Tabler only:
ti-*names (icon="ti-pencil",<i class="ti ti-user">). Nevermdi-*; it renders blank. Icon-only buttons need anaria-label. - Logout is POST-only: a form posting to
/logout, neverhref="/logout". - CSRF is enforced on every POST/PUT/DELETE. Axios on
layout.htmlpages already sends theX-CSRFTokenheader; plain HTML forms need<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">. - Pass server data through a
<script type="application/json">tag and|tojson, never by interpolating into JS. - Never mutate reactive state inside a computed property. It loops forever on Vue 3.5.
- Vuetify 4 uses Material Design 3 type classes:
text-headline-small,text-title-large,text-body-medium,text-body-small. The Vuetify 3 names (text-h5,text-body-2,text-caption) render unstyled. Align rows with classes (class="align-center justify-center"), not props. - Vuetify's CSS lives in cascade layers, so unlayered CSS beats every component style. Scope custom rules to your own classes; put element rules such as
a { }in@layer vuetify-core.reset.
Queries (SQLAlchemy 2.x)
users = db.session.execute(db.select(User).where(User.active == True)).scalars().all()
user = db.get_or_404(User, id)
pagination = db.paginate(db.select(User), page=1, per_page=25)
orders = db.session.execute(db.select(Order).join(User).where(User.id == user_id)).scalars().all()
Celery Tasks
Optional (uv sync --extra full). enferno.tasks.celery is None when Celery is not installed or configured.
from enferno.tasks import celery
@celery.task
def process_order(order_id):
...
Dependency Upgrades
uv lock --upgrade && uv sync --extra dev --extra full, then uv run --with pip-audit pip-audit and uv run pytest. Bump vendored JS by editing the pins in vendor.sh and running it. Read the Flask-Security changelog for changed defaults, and load an admin page in a real browser: a Vue upgrade once froze admin pages while every Python check passed.
More Patterns
Relationships, forms, uploads, search/filtering, dialogs and error handling: references/patterns.md.