from __future__ import annotations from django.db import models from django.contrib.auth import get_user_model from django.utils.text import slugify from textwrap import dedent from django.utils import timezone from taggit.managers import TaggableManager from mptt.models import MPTTModel, TreeForeignKey from markdownfield.models import MarkdownField, RenderedMarkdownField from markdownfield.validators import VALIDATOR_STANDARD from orgs.models import Organization from typing import Optional User = get_user_model() class PostCategory(MPTTModel): organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="cms_categories", null=True, blank=True) name = models.CharField(max_length=120) slug = models.SlugField(max_length=140, unique=False, blank=True) description = models.TextField(blank=True) parent = TreeForeignKey( "self", on_delete=models.CASCADE, null=True, blank=True, related_name="children" ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class MPTTMeta: order_insertion_by = ["name"] class Meta: ordering = ("tree_id", "lft", "name") indexes = [ models.Index(fields=["slug"]), ] unique_together = (("organization", "slug"),) def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.name)[:140] super().save(*args, **kwargs) def __str__(self) -> str: # pragma: no cover - trivial return self.name class Post(models.Model): STATUS_DRAFT = "draft" STATUS_PUBLISHED = "published" STATUS_CHOICES = ( (STATUS_DRAFT, "Draft"), (STATUS_PUBLISHED, "Published"), ) organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="cms_posts", null=True, blank=True) title = models.CharField(max_length=200) slug = models.SlugField(max_length=220, unique=False, blank=True) category = TreeForeignKey(PostCategory, on_delete=models.PROTECT, related_name="posts") author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="posts") excerpt = models.TextField(blank=True) feature_image = models.ImageField(upload_to="posts/feature/%Y/%m/%d/", null=True, blank=True) # Markdown content source + auto-rendered HTML content = MarkdownField(rendered_field="content_html", validator=VALIDATOR_STANDARD) content_html = RenderedMarkdownField(blank=True, null=True) status = models.CharField(max_length=12, choices=STATUS_CHOICES, default=STATUS_DRAFT) published_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) # Optional tags, consistent with project usage tags = TaggableManager(blank=True) class Meta: ordering = ("-published_at", "-created_at", "-id") indexes = [ models.Index(fields=["status", "published_at"]), models.Index(fields=["slug"]), ] unique_together = (("organization", "slug"),) def save(self, *args, **kwargs): if not self.slug and self.title: base = slugify(self.title)[:220].strip('-') slug = base or "post" # Ensure uniqueness within organization by appending -2, -3, ... if needed i = 2 qs = Post.objects if self.organization_id: qs = qs.filter(organization_id=self.organization_id) while qs.filter(slug=slug).exclude(pk=self.pk).exists(): suffix = f"-{i}" slug = (base[: 220 - len(suffix)] + suffix).strip('-') or f"post-{i}" i += 1 self.slug = slug if self.status == self.STATUS_PUBLISHED and not self.published_at: self.published_at = timezone.now() super().save(*args, **kwargs) def __str__(self) -> str: # pragma: no cover - trivial return self.title # --- Demo data helpers ------------------------------------------------------- def create_demo_data(org: Optional[Organization] = None) -> dict: """Create demo categories and posts for CMS. Idempotent: safe to call multiple times. Returns a summary counts dict. """ created = {"categories": 0, "posts": 0} # Categories cat_specs = [ {"name": "News", "description": "Company news and announcements"}, {"name": "Guides", "description": "How-to guides and tutorials"}, {"name": "Updates", "description": "Product updates and release notes"}, ] cats: dict[str, PostCategory] = {} for spec in cat_specs: slug = slugify(spec["name"])[:140] lookup = {"slug": slug} if org is not None: lookup["organization"] = org obj, was_created = PostCategory.objects.get_or_create( **lookup, defaults={ "name": spec["name"], "description": spec.get("description", ""), "organization": org, }, ) if was_created: created["categories"] += 1 cats[obj.name] = obj # Choose an author if available try: author = User.objects.filter(is_staff=True).order_by("id").first() or User.objects.order_by("id").first() except Exception: author = None # Posts posts_specs = [ { "title": "Welcome to Ecoloop", "category": cats.get("News"), "excerpt": "Introducing our recycling operations platform.", "content": "# Welcome to Ecoloop\n\nEcoloop helps factories and recyclers manage pickups, pricing, and a scrap marketplace.\nThis is a demo post seeded for your environment.", "status": Post.STATUS_PUBLISHED, "tags": ["ecoloop", "news"], }, { "title": "Getting Started Guide", "category": cats.get("Guides"), "excerpt": "How to create materials, customers, and pickups.", "content": "# Getting Started\n\n1. Create your organization materials and price list.\n2. Add customers and sites.\n3. Create a pickup and record a weigh ticket.\n4. Generate invoices and payouts.", "status": Post.STATUS_PUBLISHED, "tags": ["guide", "howto"], }, { "title": "September Release Notes", "category": cats.get("Updates"), "excerpt": "Highlights from the latest release.", "content": "# Release Notes\n\n- New scrap listings and bids\n- Auto award expired listings\n- Admin UI for pickups and invoices", "status": Post.STATUS_DRAFT, "tags": ["updates", "releases"], }, ] for spec in posts_specs: base_slug = slugify(spec["title"])[:220] lookup = {"slug": base_slug} if org is not None: lookup["organization"] = org # Normalize multiline markdown content to avoid code-block wrapping raw_content = spec.get("content", "") content_md = dedent(raw_content).strip() excerpt_text = spec.get("excerpt", "") if isinstance(excerpt_text, str): excerpt_text = dedent(excerpt_text).strip() post, was_created = Post.objects.get_or_create( **lookup, defaults={ "title": spec["title"], "category": spec.get("category") or next(iter(cats.values())), "author": author, "excerpt": excerpt_text, "content": content_md, "status": spec.get("status", Post.STATUS_DRAFT), "organization": org, }, ) if was_created: created["posts"] += 1 try: tags = spec.get("tags") or [] if tags: post.tags.set(tags) except Exception: pass return created