Compare commits
No commits in common. "01c6bb3d09f2e3140edb5e0cf27f5771f011b021" and "244f0945bbe125bd819fba18c8c2846b6cdba29b" have entirely different histories.
01c6bb3d09
...
244f0945bb
6 changed files with 20 additions and 147 deletions
|
|
@ -5,5 +5,4 @@ from .utils import (
|
||||||
deep_diff,
|
deep_diff,
|
||||||
diff_to_patch,
|
diff_to_patch,
|
||||||
filter_to_columns,
|
filter_to_columns,
|
||||||
to_jsonable,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
from sqlalchemy.sql import operators
|
from sqlalchemy.sql import operators
|
||||||
from sqlalchemy.sql.elements import UnaryExpression, ColumnElement
|
from sqlalchemy.sql.elements import UnaryExpression, ColumnElement
|
||||||
|
|
||||||
from crudkit.core import to_jsonable, deep_diff, diff_to_patch, filter_to_columns, normalize_payload
|
from crudkit.core import deep_diff, diff_to_patch, filter_to_columns, normalize_payload
|
||||||
from crudkit.core.base import Version
|
from crudkit.core.base import Version
|
||||||
from crudkit.core.spec import CRUDSpec
|
from crudkit.core.spec import CRUDSpec
|
||||||
from crudkit.core.types import OrderSpec, SeekWindow
|
from crudkit.core.types import OrderSpec, SeekWindow
|
||||||
|
|
@ -731,23 +731,19 @@ class CRUDService(Generic[T]):
|
||||||
|
|
||||||
def _log_version(self, change_type: str, obj: T, actor=None, metadata: dict | None = None):
|
def _log_version(self, change_type: str, obj: T, actor=None, metadata: dict | None = None):
|
||||||
session = self.session
|
session = self.session
|
||||||
|
snapshot = {}
|
||||||
try:
|
try:
|
||||||
snapshot = {}
|
snapshot = obj.as_dict()
|
||||||
try:
|
except Exception:
|
||||||
snapshot = obj.as_dict()
|
snapshot = {"error": "serialize failed"}
|
||||||
except Exception:
|
|
||||||
snapshot = {"error": "serialize failed"}
|
|
||||||
|
|
||||||
version = Version(
|
version = Version(
|
||||||
model_name=self.model.__name__,
|
model_name=self.model.__name__,
|
||||||
object_id=obj.id,
|
object_id=obj.id,
|
||||||
change_type=change_type,
|
change_type=change_type,
|
||||||
data=to_jsonable(snapshot),
|
data=snapshot,
|
||||||
actor=str(actor) if actor else None,
|
actor=str(actor) if actor else None,
|
||||||
meta=to_jsonable(metadata) if metadata else None,
|
meta=metadata or None,
|
||||||
)
|
)
|
||||||
session.add(version)
|
session.add(version)
|
||||||
session.commit()
|
session.commit()
|
||||||
except Exception as e:
|
|
||||||
log.warning(f"Version logging failed for {self.model.__name__} id={getattr(obj, "id", "?")}: {str(e)}")
|
|
||||||
session.rollback()
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from decimal import Decimal
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Any, Dict, Optional, Callable
|
from typing import Any, Dict, Optional, Callable
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
@ -10,32 +8,6 @@ ISO_DT_FORMATS = ("%Y-%m-%dT%H:%M:%S.%f",
|
||||||
"%Y-%m-%d %H:%M",
|
"%Y-%m-%d %H:%M",
|
||||||
"%Y-%m-%d")
|
"%Y-%m-%d")
|
||||||
|
|
||||||
def to_jsonable(obj: Any):
|
|
||||||
"""Recursively convert values into JSON-serializable forms."""
|
|
||||||
if obj is None or isinstance(obj, (str, int, float, bool)):
|
|
||||||
return obj
|
|
||||||
|
|
||||||
if isinstance(obj, (datetime, date)):
|
|
||||||
return obj.isoformat()
|
|
||||||
|
|
||||||
if isinstance(obj, Decimal):
|
|
||||||
return float(obj)
|
|
||||||
|
|
||||||
if isinstance(obj, Enum):
|
|
||||||
return obj.value
|
|
||||||
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return {str(k): to_jsonable(v) for k, v in obj.items()}
|
|
||||||
|
|
||||||
if isinstance(obj, (list, tuple, set)):
|
|
||||||
return [to_jsonable(v) for v in obj]
|
|
||||||
|
|
||||||
# fallback: strin-ify weird objects (UUID, ORM instances, etc.)
|
|
||||||
try:
|
|
||||||
return str(obj)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def filter_to_columns(data: dict, model_cls):
|
def filter_to_columns(data: dict, model_cls):
|
||||||
cols = {c.key for c in inspect(model_cls).mapper.columns}
|
cols = {c.key for c in inspect(model_cls).mapper.columns}
|
||||||
return {k: v for k, v in data.items() if k in cols}
|
return {k: v for k, v in data.items() if k in cols}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from jinja_markdown import MarkdownExtension
|
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
from sqlalchemy import event
|
from sqlalchemy import event
|
||||||
from sqlalchemy.pool import Pool
|
from sqlalchemy.pool import Pool
|
||||||
|
|
@ -19,7 +18,6 @@ from .routes.search import init_search_routes
|
||||||
|
|
||||||
def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
def create_app(config_cls=crudkit.DevConfig) -> Flask:
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.jinja_env.add_extension(MarkdownExtension)
|
|
||||||
|
|
||||||
init_pretty(app)
|
init_pretty(app)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,106 +1,15 @@
|
||||||
{% set items = (field.template_ctx.instance.updates or []) %}
|
{% set items = (field.template_ctx.instance.updates or []) %}
|
||||||
|
|
||||||
<ul class="list-group mt-3">
|
<ul class="list-group mt-3">
|
||||||
{% for n in items %}
|
{% for n in items %}
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div class="me-3 w-100" id="editContainer{{ n.id }}"></div>
|
<div class="me-3" style="white-space: pre-wrap;">{{ n.content }}</div>
|
||||||
|
<small class="text-muted">
|
||||||
<div class="d-flex flex-column align-items-end">
|
{{ n.timestamp.strftime("%Y-%m-%d %H:%M") if n.timestamp else "" }}
|
||||||
<div id="editView{{ n.id }}">
|
</small>
|
||||||
<small class="text-muted text-nowrap">
|
|
||||||
{{ n.timestamp.strftime("%Y-%m-%d %H:%M") if n.timestamp else "" }}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input type="checkbox" class="form-check-input" id="editSwitch{{ n.id }}" switch onchange="changeMode({{ n.id }})">
|
|
||||||
<label for="editSwitch" class="form-check-label">Edit</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="list-group-item text-muted">No updates yet.</li>
|
<li class="list-group-item text-muted">No updates yet.</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
|
|
||||||
<script>
|
|
||||||
const contents = {};
|
|
||||||
{% for n in items %}
|
|
||||||
contents[{{ n.id }}] = {{ n.content | tojson }};
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
for (const [id, md] of Object.entries(contents)) {
|
|
||||||
renderView(+id, md);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderView(id, md) {
|
|
||||||
const container = document.getElementById(`editContainer${id}`);
|
|
||||||
const html = marked.parse(md ?? "");
|
|
||||||
|
|
||||||
container.innerHTML = DOMPurify.sanitize(html, {
|
|
||||||
ADD_ATTR: ['target', 'rel'],
|
|
||||||
});
|
|
||||||
for (const a of container.querySelectorAll('a[href]')) {
|
|
||||||
a.setAttribute('target', '_blank');
|
|
||||||
a.setAttribute('rel', 'noopener noreferrer nofollow')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeMode(id) {
|
|
||||||
const container = document.getElementById(`editContainer${id}`);
|
|
||||||
const toggle = document.getElementById(`editSwitch${id}`);
|
|
||||||
|
|
||||||
if (toggle.checked) {
|
|
||||||
// Switch to editor mode
|
|
||||||
const current = contents[id] ?? "";
|
|
||||||
container.dataset.prev = container.innerHTML;
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<textarea class="form-control w-100" id="editor${id}" rows="6">${escapeForTextarea(current)}</textarea>
|
|
||||||
<div class="mt-2 d-flex gap-2">
|
|
||||||
<button class="btn btn-primary btn-sm" onclick="saveEdit(${id})">Save</button>
|
|
||||||
<button class="btn btn-secondary btn-sm" onclick="cancelEdit(${id})">Cancel</button>
|
|
||||||
<button class="btn btn-outline-secondary btn-sm" onclick="togglePreview(${id})">Preview</button>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 border rounded p-2 d-none" id="preview${id}"></div>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
// Switch to viewer mode
|
|
||||||
renderView(id, contents[id]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveEdit(id) {
|
|
||||||
const textarea = document.getElementById(`editor${id}`);
|
|
||||||
const value = textarea.value;
|
|
||||||
contents[id] = value;
|
|
||||||
renderView(id, value);
|
|
||||||
|
|
||||||
document.getElementById(`editSwitch${id}`).checked = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEdit(id) {
|
|
||||||
document.getElementById(`editSwitch${id}`).checked = false;
|
|
||||||
renderView(id, contents[id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePreview(id) {
|
|
||||||
const textarea = document.getElementById(`editor${id}`);
|
|
||||||
const preview = document.getElementById(`preview${id}`);
|
|
||||||
preview.classList.toggle('d-none');
|
|
||||||
if (!preview.classList.contains('d-none')) {
|
|
||||||
const html = marked.parse(textarea.value ?? "");
|
|
||||||
preview.innerHTML = DOMPurify.sanitize(html);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeForTextarea(s) {
|
|
||||||
// Keep control of what goes inside the textarea
|
|
||||||
return (s ?? "").replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
@ -9,7 +9,6 @@ dependencies = [
|
||||||
"flask",
|
"flask",
|
||||||
"flask_sqlalchemy",
|
"flask_sqlalchemy",
|
||||||
"html5lib",
|
"html5lib",
|
||||||
"jinja_markdown",
|
|
||||||
"pandas",
|
"pandas",
|
||||||
"pyodbc",
|
"pyodbc",
|
||||||
"python-dotenv",
|
"python-dotenv",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue