diff --git a/asciidoctor_sandbox/__init__.py b/asciidoctor_sandbox/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/asciidoctor_sandbox/admin.py b/asciidoctor_sandbox/admin.py new file mode 100644 index 00000000..bb385cfa --- /dev/null +++ b/asciidoctor_sandbox/admin.py @@ -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) diff --git a/asciidoctor_sandbox/apps.py b/asciidoctor_sandbox/apps.py new file mode 100644 index 00000000..13ca38bb --- /dev/null +++ b/asciidoctor_sandbox/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AsciidoctorSandboxConfig(AppConfig): + name = "asciidoctor_sandbox" + verbose_name = "Asciidoctor Sandbox" diff --git a/asciidoctor_sandbox/constants.py b/asciidoctor_sandbox/constants.py new file mode 100644 index 00000000..62ba188e --- /dev/null +++ b/asciidoctor_sandbox/constants.py @@ -0,0 +1,4 @@ +# Asciidoctor Sandbox Configuration + +# Number of days after which sandbox documents are eligible for cleanup +ASCIIDOCTOR_SANDBOX_DOCUMENT_RETENTION_DAYS = 365 diff --git a/asciidoctor_sandbox/management/__init__.py b/asciidoctor_sandbox/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/asciidoctor_sandbox/management/commands/__init__.py b/asciidoctor_sandbox/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/asciidoctor_sandbox/management/commands/cleanup_sandbox_documents.py b/asciidoctor_sandbox/management/commands/cleanup_sandbox_documents.py new file mode 100644 index 00000000..dba2af02 --- /dev/null +++ b/asciidoctor_sandbox/management/commands/cleanup_sandbox_documents.py @@ -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") + ) diff --git a/asciidoctor_sandbox/migrations/0001_initial.py b/asciidoctor_sandbox/migrations/0001_initial.py new file mode 100644 index 00000000..9ba86b81 --- /dev/null +++ b/asciidoctor_sandbox/migrations/0001_initial.py @@ -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"], + }, + ), + ] diff --git a/asciidoctor_sandbox/migrations/__init__.py b/asciidoctor_sandbox/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/asciidoctor_sandbox/models.py b/asciidoctor_sandbox/models.py new file mode 100644 index 00000000..a30b5c1a --- /dev/null +++ b/asciidoctor_sandbox/models.py @@ -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}>" diff --git a/asciidoctor_sandbox/tasks.py b/asciidoctor_sandbox/tasks.py new file mode 100644 index 00000000..c6f3ca49 --- /dev/null +++ b/asciidoctor_sandbox/tasks.py @@ -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") diff --git a/asciidoctor_sandbox/urls.py b/asciidoctor_sandbox/urls.py new file mode 100644 index 00000000..2eae6a5d --- /dev/null +++ b/asciidoctor_sandbox/urls.py @@ -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"), +] diff --git a/asciidoctor_sandbox/views.py b/asciidoctor_sandbox/views.py new file mode 100644 index 00000000..16ae0bcb --- /dev/null +++ b/asciidoctor_sandbox/views.py @@ -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"
{rendered_content}
" + + return JsonResponse({"success": True, "html": rendered_html}) diff --git a/config/celery.py b/config/celery.py index 3027f77e..42834bab 100644 --- a/config/celery.py +++ b/config/celery.py @@ -106,3 +106,9 @@ def setup_periodic_tasks(sender, **kwargs): crontab(hour=3, minute=30), 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"), + ) diff --git a/config/settings.py b/config/settings.py index 98689abf..dd95db59 100755 --- a/config/settings.py +++ b/config/settings.py @@ -99,6 +99,7 @@ INSTALLED_APPS += [ "reports", "core", "slack", + "asciidoctor_sandbox", ] AUTH_USER_MODEL = "users.User" diff --git a/config/urls.py b/config/urls.py index 169962aa..df6f8da2 100755 --- a/config/urls.py +++ b/config/urls.py @@ -173,6 +173,7 @@ urlpatterns = ( name="docs", ), path("health/", include("health_check.urls")), + path("asciidoctor_sandbox/", include("asciidoctor_sandbox.urls")), # temp page for community until mailman is done. path( "community/", diff --git a/core/admin_filters.py b/core/admin_filters.py new file mode 100644 index 00000000..bc3c54dd --- /dev/null +++ b/core/admin_filters.py @@ -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 diff --git a/templates/admin/asciidoctor_sandbox_doc_change_form.html b/templates/admin/asciidoctor_sandbox_doc_change_form.html new file mode 100644 index 00000000..5f8f1b85 --- /dev/null +++ b/templates/admin/asciidoctor_sandbox_doc_change_form.html @@ -0,0 +1,138 @@ +{% extends "admin/change_form.html" %} +{% load static %} + +{% block extrahead %} +{{ block.super }} + +{% endblock %} + +{% block content %} +
+
+ {{ block.super }} +
+
+
+ Live Preview (Saving is optional) + +
+
+
Click "Preview" to see rendered content...
+
+
+
+ + +{% endblock %}