Added asciidoctor preview sandbox (#1928) (#1934)

This commit is contained in:
daveoconnor
2025-09-26 08:29:20 -07:00
committed by GitHub
parent c7571ae569
commit ef9839e3ad
18 changed files with 364 additions and 0 deletions

View File

View File

@@ -0,0 +1,33 @@
from django.contrib import admin
from django.contrib.auth import get_user_model
from core.admin_filters import StaffUserCreatedByFilter
from .models import SandboxDocument
User = get_user_model()
@admin.register(SandboxDocument)
class SandboxDocumentAdmin(admin.ModelAdmin):
list_display = ("title", "created_by", "created_at", "updated_at")
list_filter = ("created_at", "updated_at", StaffUserCreatedByFilter)
search_fields = ("title", "asciidoc_content")
readonly_fields = ("created_at", "updated_at", "created_by")
ordering = ("-updated_at",)
change_form_template = "admin/asciidoctor_sandbox_doc_change_form.html"
fieldsets = (
(None, {"fields": ("title", "asciidoc_content")}),
(
"Metadata",
{
"fields": ("created_by", "created_at", "updated_at"),
"classes": ("collapse",),
},
),
)
def save_model(self, request, obj, form, change):
if not change:
obj.created_by = request.user
super().save_model(request, obj, form, change)

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AsciidoctorSandboxConfig(AppConfig):
name = "asciidoctor_sandbox"
verbose_name = "Asciidoctor Sandbox"

View File

@@ -0,0 +1,4 @@
# Asciidoctor Sandbox Configuration
# Number of days after which sandbox documents are eligible for cleanup
ASCIIDOCTOR_SANDBOX_DOCUMENT_RETENTION_DAYS = 365

View File

@@ -0,0 +1,16 @@
import djclick as click
from asciidoctor_sandbox.tasks import cleanup_old_sandbox_documents
@click.command()
def command():
"""Clean up old sandbox documents based on the configured retention period."""
click.echo("Starting sandbox document cleanup...")
# Run the task synchronously
cleanup_old_sandbox_documents()
click.echo(
click.style("Sandbox document cleanup completed successfully.", fg="green")
)

View File

@@ -0,0 +1,50 @@
# Generated by Django 4.2.24 on 2025-09-22 22:54
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="SandboxDocument",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(help_text="Document title", max_length=200)),
(
"asciidoc_content",
models.TextField(blank=True, help_text="Asciidoc source content"),
),
("created_at", models.DateTimeField(default=django.utils.timezone.now)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"created_by",
models.ForeignKey(
help_text="User who created this document",
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-updated_at"],
},
),
]

View File

@@ -0,0 +1,26 @@
from django.db import models
from django.utils import timezone
from django.conf import settings
class SandboxDocument(models.Model):
"""Model to store asciidoctor sandbox documents."""
title = models.CharField(max_length=200, help_text="Document title")
asciidoc_content = models.TextField(blank=True, help_text="Asciidoc source content")
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
help_text="User who created this document",
)
created_at = models.DateTimeField(default=timezone.now)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-updated_at"]
def __str__(self):
return self.title
def __repr__(self):
return f"<{self.__class__.__name__} object ({self.pk}): {self}>"

View File

@@ -0,0 +1,40 @@
from datetime import timedelta
import structlog
from celery import shared_task
from django.utils import timezone
from .constants import ASCIIDOCTOR_SANDBOX_DOCUMENT_RETENTION_DAYS
from .models import SandboxDocument
logger = structlog.get_logger(__name__)
@shared_task
def cleanup_old_sandbox_documents():
"""
Delete sandbox documents last updated before the configured retention period.
"""
cutoff_date = timezone.now() - timedelta(
days=ASCIIDOCTOR_SANDBOX_DOCUMENT_RETENTION_DAYS
)
old_documents = SandboxDocument.objects.filter(updated_at__lt=cutoff_date)
count = old_documents.count()
if count == 0:
logger.info("No old sandbox documents to clean up")
return
logger.info(
f"Deleting {count} sandbox documents older than "
f"{ASCIIDOCTOR_SANDBOX_DOCUMENT_RETENTION_DAYS} days"
)
deleted_count = 0
for document in old_documents:
logger.debug(f"Deleting sandbox document {document.id=}")
document.delete()
deleted_count += 1
logger.info(f"Successfully deleted {deleted_count} old sandbox documents")

View File

@@ -0,0 +1,8 @@
from django.urls import path
from . import views
app_name = "asciidoctor_sandbox"
urlpatterns = [
path("admin-preview/", views.admin_preview, name="admin_preview"),
]

View File

@@ -0,0 +1,17 @@
from django.http import JsonResponse
from django.contrib.admin.views.decorators import staff_member_required
from django.views.decorators.http import require_POST
import json
from core.asciidoc import convert_adoc_to_html
@staff_member_required
@require_POST
def admin_preview(request):
"""Preview asciidoc content for admin interface."""
data = json.loads(request.body)
rendered_content = convert_adoc_to_html(data.get("content", ""))
rendered_html = f"<div class='preview-content'>{rendered_content}</div>"
return JsonResponse({"success": True, "html": rendered_html})

View File

@@ -106,3 +106,9 @@ def setup_periodic_tasks(sender, **kwargs):
crontab(hour=3, minute=30), crontab(hour=3, minute=30),
app.signature("users.tasks.refresh_users_github_photos"), app.signature("users.tasks.refresh_users_github_photos"),
) )
# Clean up old sandbox documents. Executes weekly on Sundays at 2:00 AM.
sender.add_periodic_task(
crontab(day_of_week="sun", hour=2, minute=0),
app.signature("asciidoctor_sandbox.tasks.cleanup_old_sandbox_documents"),
)

View File

@@ -99,6 +99,7 @@ INSTALLED_APPS += [
"reports", "reports",
"core", "core",
"slack", "slack",
"asciidoctor_sandbox",
] ]
AUTH_USER_MODEL = "users.User" AUTH_USER_MODEL = "users.User"

View File

@@ -173,6 +173,7 @@ urlpatterns = (
name="docs", name="docs",
), ),
path("health/", include("health_check.urls")), path("health/", include("health_check.urls")),
path("asciidoctor_sandbox/", include("asciidoctor_sandbox.urls")),
# temp page for community until mailman is done. # temp page for community until mailman is done.
path( path(
"community/", "community/",

18
core/admin_filters.py Normal file
View File

@@ -0,0 +1,18 @@
from django.contrib import admin
from django.contrib.auth import get_user_model
User = get_user_model()
class StaffUserCreatedByFilter(admin.SimpleListFilter):
title = "creator (staff only)"
parameter_name = "created_by"
def lookups(self, request, model_admin):
staff_users = User.objects.filter(is_staff=True).order_by("display_name")
return [(user.id, user.display_name or user.email) for user in staff_users]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(created_by=self.value())
return queryset

View File

@@ -0,0 +1,138 @@
{% extends "admin/change_form.html" %}
{% load static %}
{% block extrahead %}
{{ block.super }}
<style>
.split-container {
display: flex;
gap: 20px;
margin-top: 20px;
width: 100%;
}
.content-editor {
flex: 1;
max-width: 50%;
}
.content-preview {
flex: 1;
border: 1px solid #ddd;
min-height: 400px;
overflow-y: auto;
}
.preview-header {
background: #e9e9e9;
color: #333;
padding: 10px 15px;
font-weight: bold;
border-bottom: 1px solid #ccc;
display: flex;
justify-content: space-between;
align-items: center;
}
.preview-content {
padding: 15px;
min-height: 350px;
}
#id_asciidoc_content {
min-height: 400px;
font-family: monospace;
}
.preview-loading {
color: #666;
font-style: italic;
}
.preview-btn {
background: #417690;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.preview-btn:hover {
background: #205067;
}
.preview-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.saving_optional {
font-weight: normal;
font-size: 10px;
color: #666;
}
</style>
{% endblock %}
{% block content %}
<div class="split-container">
<div class="content-editor">
{{ block.super }}
</div>
<div class="content-preview">
<div class="preview-header">
Live Preview <span class="saving_optional">(Saving is optional)</span>
<button type="button" class="preview-btn" id="preview-btn">Preview</button>
</div>
<div class="preview-content" id="preview-content">
<div class="preview-loading">Click "Preview" to see rendered content...</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const textArea = document.getElementById('id_asciidoc_content');
const previewDiv = document.getElementById('preview-content');
const previewBtn = document.getElementById('preview-btn');
function updatePreview() {
const content = textArea.value;
if (!content.trim()) {
previewDiv.innerHTML = '<div class="preview-loading">No content to preview...</div>';
return;
}
previewBtn.disabled = true;
previewBtn.textContent = 'Loading...';
previewDiv.innerHTML = '<div class="preview-loading">Generating preview...</div>';
fetch('{% url "asciidoctor_sandbox:admin_preview" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
},
body: JSON.stringify({
content: content
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
previewDiv.innerHTML = data.html;
} else {
previewDiv.innerHTML = `<div style="color: red;">Error: ${data.error}</div>`;
}
})
.catch(error => {
previewDiv.innerHTML = `<div style="color: red;">Network error: ${error.message}</div>`;
})
.finally(() => {
previewBtn.disabled = false;
previewBtn.textContent = 'Preview';
});
}
if (previewBtn && textArea) {
previewBtn.addEventListener('click', updatePreview);
if (textArea.value.trim()) {
updatePreview();
}
}
});
</script>
{% endblock %}