Compare commits

..

2 commits

Author SHA1 Message Date
Yaro Kasear
01c6bb3d09 Edit logic implemented. 2025-10-02 10:09:25 -05:00
Yaro Kasear
16d5f2ab98 Improving the Work Item rendering. 2025-10-02 09:55:53 -05:00
6 changed files with 147 additions and 20 deletions

View file

@ -5,4 +5,5 @@ from .utils import (
deep_diff,
diff_to_patch,
filter_to_columns,
to_jsonable,
)

View file

@ -10,7 +10,7 @@ from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlalchemy.sql import operators
from sqlalchemy.sql.elements import UnaryExpression, ColumnElement
from crudkit.core import deep_diff, diff_to_patch, filter_to_columns, normalize_payload
from crudkit.core import to_jsonable, deep_diff, diff_to_patch, filter_to_columns, normalize_payload
from crudkit.core.base import Version
from crudkit.core.spec import CRUDSpec
from crudkit.core.types import OrderSpec, SeekWindow
@ -731,6 +731,7 @@ class CRUDService(Generic[T]):
def _log_version(self, change_type: str, obj: T, actor=None, metadata: dict | None = None):
session = self.session
try:
snapshot = {}
try:
snapshot = obj.as_dict()
@ -741,9 +742,12 @@ class CRUDService(Generic[T]):
model_name=self.model.__name__,
object_id=obj.id,
change_type=change_type,
data=snapshot,
data=to_jsonable(snapshot),
actor=str(actor) if actor else None,
meta=metadata or None,
meta=to_jsonable(metadata) if metadata else None,
)
session.add(version)
session.commit()
except Exception as e:
log.warning(f"Version logging failed for {self.model.__name__} id={getattr(obj, "id", "?")}: {str(e)}")
session.rollback()

View file

@ -1,5 +1,7 @@
from __future__ import annotations
from datetime import datetime, date
from decimal import Decimal
from enum import Enum
from typing import Any, Dict, Optional, Callable
from sqlalchemy import inspect
@ -8,6 +10,32 @@ ISO_DT_FORMATS = ("%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%d %H:%M",
"%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):
cols = {c.key for c in inspect(model_cls).mapper.columns}
return {k: v for k, v in data.items() if k in cols}

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from flask import Flask
from jinja_markdown import MarkdownExtension
from sqlalchemy.engine import Engine
from sqlalchemy import event
from sqlalchemy.pool import Pool
@ -18,6 +19,7 @@ from .routes.search import init_search_routes
def create_app(config_cls=crudkit.DevConfig) -> Flask:
app = Flask(__name__)
app.jinja_env.add_extension(MarkdownExtension)
init_pretty(app)

View file

@ -1,15 +1,106 @@
{% set items = (field.template_ctx.instance.updates or []) %}
<ul class="list-group mt-3">
{% for n in items %}
<li class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="me-3" style="white-space: pre-wrap;">{{ n.content }}</div>
<small class="text-muted">
<div class="me-3 w-100" id="editContainer{{ n.id }}"></div>
<div class="d-flex flex-column align-items-end">
<div id="editView{{ n.id }}">
<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>
</li>
{% else %}
<li class="list-group-item text-muted">No updates yet.</li>
{% endfor %}
</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
</script>

View file

@ -9,6 +9,7 @@ dependencies = [
"flask",
"flask_sqlalchemy",
"html5lib",
"jinja_markdown",
"pandas",
"pyodbc",
"python-dotenv",