generated from westfarn/web_django_template
Initial commit
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from core.models import StoredFile
|
||||
|
||||
|
||||
@admin.register(StoredFile)
|
||||
class StoredFileAdmin(admin.ModelAdmin):
|
||||
list_display = ("filename", "kind", "content_type", "size", "created_at")
|
||||
list_filter = ("kind", "content_type")
|
||||
readonly_fields = ("size", "content_type", "created_at", "updated_at")
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "core"
|
||||
|
||||
def ready(self):
|
||||
from core.registry import register_feature
|
||||
|
||||
register_feature("core")
|
||||
@@ -0,0 +1,9 @@
|
||||
from core.registry import enabled_features, portal_nav, public_nav
|
||||
|
||||
|
||||
def feature_registry(request):
|
||||
return {
|
||||
"portal_nav": portal_nav(),
|
||||
"public_nav_extra": public_nav(),
|
||||
"enabled_features": enabled_features(),
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from core.registry import run_dispatchers
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Enqueue due scheduled work registered by installed apps. "
|
||||
"Pass --loop to poll until stopped (worker entrypoint)."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--loop",
|
||||
action="store_true",
|
||||
help="Poll forever until SIGINT/SIGTERM.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interval",
|
||||
type=int,
|
||||
default=15,
|
||||
help="Seconds between polls when --loop is set (default 15).",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.verbosity = int(options.get("verbosity", 1))
|
||||
if options["loop"]:
|
||||
interval = max(1, int(options["interval"] or 15))
|
||||
self.stdout.write(f"Polling due scheduled work every {interval}s.")
|
||||
try:
|
||||
while True:
|
||||
self._dispatch_once()
|
||||
time.sleep(interval)
|
||||
except KeyboardInterrupt:
|
||||
self.stdout.write("dispatch_due loop stopped.")
|
||||
return
|
||||
self._dispatch_once()
|
||||
|
||||
def _dispatch_once(self):
|
||||
enqueued = run_dispatchers()
|
||||
if enqueued:
|
||||
self.stdout.write(self.style.SUCCESS(f"Enqueued {enqueued} due item(s)."))
|
||||
elif self.verbosity >= 2:
|
||||
self.stdout.write("No due items.")
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 6.1 on 2026-08-26 11:38
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='StoredFile',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('kind', models.CharField(choices=[('campaign_image', 'Campaign image'), ('social_image', 'Social image'), ('social_video', 'Social video'), ('invoice_pdf', 'Invoice PDF')], default='campaign_image', max_length=32)),
|
||||
('filename', models.CharField(blank=True, max_length=255)),
|
||||
('content_type', models.CharField(max_length=128)),
|
||||
('size', models.PositiveIntegerField(default=0)),
|
||||
('data', models.BinaryField()),
|
||||
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_files', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class TimeStampedModel(models.Model):
|
||||
"""Abstract base with created/updated timestamps."""
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class UUIDPrimaryKeyModel(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class StoredFile(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
"""Binary file blob in the database (no filesystem media storage)."""
|
||||
|
||||
class Kind(models.TextChoices):
|
||||
CAMPAIGN_IMAGE = "campaign_image", "Campaign image"
|
||||
SOCIAL_IMAGE = "social_image", "Social image"
|
||||
SOCIAL_VIDEO = "social_video", "Social video"
|
||||
INVOICE_PDF = "invoice_pdf", "Invoice PDF"
|
||||
|
||||
kind = models.CharField(
|
||||
max_length=32, choices=Kind.choices, default=Kind.CAMPAIGN_IMAGE
|
||||
)
|
||||
filename = models.CharField(max_length=255, blank=True)
|
||||
content_type = models.CharField(max_length=128)
|
||||
size = models.PositiveIntegerField(default=0)
|
||||
data = models.BinaryField()
|
||||
uploaded_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="uploaded_files",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.filename or str(self.pk)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Optional-app registry. Apps register from AppConfig.ready() — never from always-on imports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NavItem:
|
||||
section: str
|
||||
label: str
|
||||
url_name: str
|
||||
group: str = "Overview"
|
||||
order: int = 100
|
||||
|
||||
|
||||
GROUP_ORDER = {
|
||||
"Overview": 10,
|
||||
"Outreach": 20,
|
||||
"Content": 30,
|
||||
"Social": 40,
|
||||
"Billing": 50,
|
||||
}
|
||||
|
||||
_portal_nav: list[NavItem] = []
|
||||
_public_nav: list[NavItem] = []
|
||||
_dashboard_collectors: list[Callable] = []
|
||||
_dispatchers: list[Callable] = []
|
||||
_features: set[str] = set()
|
||||
|
||||
|
||||
def register_feature(name: str) -> None:
|
||||
_features.add(name)
|
||||
|
||||
|
||||
def enabled_features() -> frozenset[str]:
|
||||
return frozenset(_features)
|
||||
|
||||
|
||||
def register_portal_nav(
|
||||
*,
|
||||
section: str,
|
||||
label: str,
|
||||
url_name: str,
|
||||
group: str = "Overview",
|
||||
order: int = 100,
|
||||
) -> None:
|
||||
item = NavItem(
|
||||
section=section, label=label, url_name=url_name, group=group, order=order
|
||||
)
|
||||
_portal_nav.append(item)
|
||||
|
||||
|
||||
def register_public_nav(
|
||||
*,
|
||||
section: str,
|
||||
label: str,
|
||||
url_name: str,
|
||||
order: int = 100,
|
||||
) -> None:
|
||||
_public_nav.append(
|
||||
NavItem(section=section, label=label, url_name=url_name, order=order)
|
||||
)
|
||||
|
||||
|
||||
def register_dashboard_collector(fn: Callable) -> None:
|
||||
"""fn(request) -> dict merged into dashboard home context."""
|
||||
_dashboard_collectors.append(fn)
|
||||
|
||||
|
||||
def register_dispatcher(fn: Callable) -> None:
|
||||
"""fn() -> int count of items enqueued. Called by dispatch_due."""
|
||||
_dispatchers.append(fn)
|
||||
|
||||
|
||||
def portal_nav() -> list[NavItem]:
|
||||
return sorted(
|
||||
_portal_nav,
|
||||
key=lambda i: (GROUP_ORDER.get(i.group, 99), i.order, i.label),
|
||||
)
|
||||
|
||||
|
||||
def public_nav() -> list[NavItem]:
|
||||
return sorted(_public_nav, key=lambda i: (i.order, i.label))
|
||||
|
||||
|
||||
def collect_dashboard_context(request) -> dict:
|
||||
extra: dict = {}
|
||||
for fn in _dashboard_collectors:
|
||||
extra.update(fn(request) or {})
|
||||
return extra
|
||||
|
||||
|
||||
def run_dispatchers() -> int:
|
||||
total = 0
|
||||
for fn in _dispatchers:
|
||||
total += int(fn() or 0)
|
||||
return total
|
||||
|
||||
|
||||
def reverse_nav(item: NavItem) -> str:
|
||||
return reverse(item.url_name)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Shared schedule parsing for campaigns and social posts."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def parse_scheduled_for(raw: str | None):
|
||||
"""Parse optional ``datetime-local`` value into an aware datetime."""
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid schedule datetime.") from exc
|
||||
if timezone.is_naive(parsed):
|
||||
return timezone.make_aware(parsed, timezone.get_current_timezone())
|
||||
return parsed
|
||||
@@ -0,0 +1,39 @@
|
||||
from io import StringIO
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import Client, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
class HealthzTests(TestCase):
|
||||
def test_healthz_ok(self):
|
||||
response = Client().get("/healthz/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["status"], "ok")
|
||||
|
||||
|
||||
class UnderConstructionTests(TestCase):
|
||||
@override_settings(SITE_UNDER_CONSTRUCTION=True)
|
||||
def test_home_redirects_when_gated(self):
|
||||
response = Client().get("/")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/under-construction", response["Location"])
|
||||
|
||||
@override_settings(SITE_UNDER_CONSTRUCTION=False)
|
||||
def test_home_ok_when_open(self):
|
||||
response = Client().get("/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class PublicSmokeTests(TestCase):
|
||||
def test_about_and_contact_get(self):
|
||||
client = Client()
|
||||
self.assertEqual(client.get(reverse("public:about")).status_code, 200)
|
||||
self.assertEqual(client.get(reverse("public:contact")).status_code, 200)
|
||||
|
||||
|
||||
class DispatchDueTests(TestCase):
|
||||
def test_quiet_when_nothing_due(self):
|
||||
out = StringIO()
|
||||
call_command("dispatch_due", stdout=out)
|
||||
self.assertEqual(out.getvalue(), "")
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Feature-flag isolation: flags control INSTALLED_APPS membership and deps."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
def _read_settings_source() -> str:
|
||||
return (Path(__file__).resolve().parent.parent / "client_site/settings/base.py").read_text()
|
||||
|
||||
|
||||
class FeatureFlagSettingsTests(SimpleTestCase):
|
||||
def test_optional_apps_are_gated_in_settings(self):
|
||||
src = _read_settings_source()
|
||||
self.assertIn("FEATURE_EMAIL_SMS", src)
|
||||
self.assertIn("FEATURE_DIRECT_MAIL", src)
|
||||
self.assertIn("FEATURE_BLOG", src)
|
||||
self.assertIn("FEATURE_PAYMENTS", src)
|
||||
self.assertIn("FEATURE_SOCIAL", src)
|
||||
self.assertIn("FEATURE_SOCIAL_AI", src)
|
||||
self.assertIn("email_sms.apps.EmailSmsConfig", src)
|
||||
self.assertIn("directmail.apps.DirectmailConfig", src)
|
||||
self.assertIn("blog.apps.BlogConfig", src)
|
||||
self.assertIn("payments.apps.PaymentsConfig", src)
|
||||
self.assertIn("social_ai.apps.SocialAiConfig", src)
|
||||
|
||||
def test_payments_requires_email_sms_in_source(self):
|
||||
src = _read_settings_source()
|
||||
self.assertIn("FEATURE_PAYMENTS requires FEATURE_EMAIL_SMS", src)
|
||||
self.assertIn("FEATURE_SOCIAL_AI requires FEATURE_SOCIAL", src)
|
||||
|
||||
|
||||
class AlwaysOnImportIsolationTests(SimpleTestCase):
|
||||
"""Always-on modules must not import optional apps at module level."""
|
||||
|
||||
OPTIONAL = {
|
||||
"email_sms",
|
||||
"directmail",
|
||||
"blog",
|
||||
"payments",
|
||||
"social",
|
||||
"social_ai",
|
||||
}
|
||||
|
||||
ALWAYS_ON = [
|
||||
"dashboard/views.py",
|
||||
"public/views.py",
|
||||
"core/management/commands/dispatch_due.py",
|
||||
"client_site/urls.py",
|
||||
]
|
||||
|
||||
def test_no_module_level_optional_imports(self):
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
for rel in self.ALWAYS_ON:
|
||||
path = root / rel
|
||||
tree = ast.parse(path.read_text())
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
top = node.module.split(".")[0]
|
||||
# Allow inside functions — ast.ImportFrom at module body only
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
pass
|
||||
# Check only top-level
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
top = alias.name.split(".")[0]
|
||||
self.assertNotIn(top, self.OPTIONAL, f"{rel} imports {alias.name}")
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
top = node.module.split(".")[0]
|
||||
self.assertNotIn(top, self.OPTIONAL, f"{rel} imports {node.module}")
|
||||
|
||||
|
||||
class InstalledOptionalAppsTests(TestCase):
|
||||
"""Dev defaults install optional apps so the template is a working seed."""
|
||||
|
||||
def test_dev_installs_catalog_apps(self):
|
||||
labels = {c.label for c in apps.get_app_configs()}
|
||||
for label in ("email_sms", "directmail", "blog", "payments", "social", "social_ai"):
|
||||
self.assertIn(label, labels)
|
||||
|
||||
def test_optional_urls_resolve_when_installed(self):
|
||||
self.assertTrue(reverse("email_sms:campaign_list").startswith("/portal/messaging/"))
|
||||
self.assertTrue(reverse("directmail:postcard_designer").startswith("/portal/direct-mail/"))
|
||||
self.assertTrue(reverse("blog:list").startswith("/blog/"))
|
||||
self.assertTrue(reverse("payments:invoice_list").startswith("/portal/payments/"))
|
||||
self.assertTrue(reverse("social:composer").startswith("/portal/social/"))
|
||||
self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/"))
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from core import views
|
||||
|
||||
app_name = "core"
|
||||
|
||||
urlpatterns = [
|
||||
path("files/<uuid:pk>/", views.stored_file, name="stored_file"),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
import io
|
||||
|
||||
from django.http import FileResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.decorators.http import require_GET
|
||||
|
||||
from core.models import StoredFile
|
||||
|
||||
|
||||
def healthz(_request):
|
||||
"""Liveness probe for deploy / NPM health checks."""
|
||||
return JsonResponse({"status": "ok"})
|
||||
|
||||
|
||||
@require_GET
|
||||
def stored_file(request, pk):
|
||||
"""Public fetch (UUID acts as capability token)."""
|
||||
stored = get_object_or_404(StoredFile, pk=pk)
|
||||
response = FileResponse(
|
||||
io.BytesIO(bytes(stored.data)),
|
||||
content_type=stored.content_type or "application/octet-stream",
|
||||
)
|
||||
if stored.filename:
|
||||
response["Content-Disposition"] = f'inline; filename="{stored.filename}"'
|
||||
response["Cache-Control"] = "public, max-age=86400"
|
||||
return response
|
||||
Reference in New Issue
Block a user