mirror of
https://github.com/boostorg/website-v2.git
synced 2026-01-19 04:42:17 +00:00
0
asciidoctor_sandbox/__init__.py
Normal file
0
asciidoctor_sandbox/__init__.py
Normal file
33
asciidoctor_sandbox/admin.py
Normal file
33
asciidoctor_sandbox/admin.py
Normal 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)
|
||||||
6
asciidoctor_sandbox/apps.py
Normal file
6
asciidoctor_sandbox/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AsciidoctorSandboxConfig(AppConfig):
|
||||||
|
name = "asciidoctor_sandbox"
|
||||||
|
verbose_name = "Asciidoctor Sandbox"
|
||||||
4
asciidoctor_sandbox/constants.py
Normal file
4
asciidoctor_sandbox/constants.py
Normal 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
|
||||||
0
asciidoctor_sandbox/management/__init__.py
Normal file
0
asciidoctor_sandbox/management/__init__.py
Normal file
0
asciidoctor_sandbox/management/commands/__init__.py
Normal file
0
asciidoctor_sandbox/management/commands/__init__.py
Normal 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")
|
||||||
|
)
|
||||||
50
asciidoctor_sandbox/migrations/0001_initial.py
Normal file
50
asciidoctor_sandbox/migrations/0001_initial.py
Normal 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"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
asciidoctor_sandbox/migrations/__init__.py
Normal file
0
asciidoctor_sandbox/migrations/__init__.py
Normal file
26
asciidoctor_sandbox/models.py
Normal file
26
asciidoctor_sandbox/models.py
Normal 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}>"
|
||||||
40
asciidoctor_sandbox/tasks.py
Normal file
40
asciidoctor_sandbox/tasks.py
Normal 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")
|
||||||
8
asciidoctor_sandbox/urls.py
Normal file
8
asciidoctor_sandbox/urls.py
Normal 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"),
|
||||||
|
]
|
||||||
17
asciidoctor_sandbox/views.py
Normal file
17
asciidoctor_sandbox/views.py
Normal 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})
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ INSTALLED_APPS += [
|
|||||||
"reports",
|
"reports",
|
||||||
"core",
|
"core",
|
||||||
"slack",
|
"slack",
|
||||||
|
"asciidoctor_sandbox",
|
||||||
]
|
]
|
||||||
|
|
||||||
AUTH_USER_MODEL = "users.User"
|
AUTH_USER_MODEL = "users.User"
|
||||||
|
|||||||
@@ -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
18
core/admin_filters.py
Normal 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
|
||||||
138
templates/admin/asciidoctor_sandbox_doc_change_form.html
Normal file
138
templates/admin/asciidoctor_sandbox_doc_change_form.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user