暫無描述

models.py 7.8KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. from __future__ import annotations
  2. from django.db import models
  3. from django.contrib.auth import get_user_model
  4. from django.utils.text import slugify
  5. from textwrap import dedent
  6. from django.utils import timezone
  7. from taggit.managers import TaggableManager
  8. from mptt.models import MPTTModel, TreeForeignKey
  9. from markdownfield.models import MarkdownField, RenderedMarkdownField
  10. from markdownfield.validators import VALIDATOR_STANDARD
  11. from orgs.models import Organization
  12. from typing import Optional
  13. User = get_user_model()
  14. class PostCategory(MPTTModel):
  15. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="cms_categories", null=True, blank=True)
  16. name = models.CharField(max_length=120)
  17. slug = models.SlugField(max_length=140, unique=False, blank=True)
  18. description = models.TextField(blank=True)
  19. parent = TreeForeignKey(
  20. "self", on_delete=models.CASCADE, null=True, blank=True, related_name="children"
  21. )
  22. created_at = models.DateTimeField(auto_now_add=True)
  23. updated_at = models.DateTimeField(auto_now=True)
  24. class MPTTMeta:
  25. order_insertion_by = ["name"]
  26. class Meta:
  27. ordering = ("tree_id", "lft", "name")
  28. indexes = [
  29. models.Index(fields=["slug"]),
  30. ]
  31. unique_together = (("organization", "slug"),)
  32. def save(self, *args, **kwargs):
  33. if not self.slug:
  34. self.slug = slugify(self.name)[:140]
  35. super().save(*args, **kwargs)
  36. def __str__(self) -> str: # pragma: no cover - trivial
  37. return self.name
  38. class Post(models.Model):
  39. STATUS_DRAFT = "draft"
  40. STATUS_PUBLISHED = "published"
  41. STATUS_CHOICES = (
  42. (STATUS_DRAFT, "Draft"),
  43. (STATUS_PUBLISHED, "Published"),
  44. )
  45. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="cms_posts", null=True, blank=True)
  46. title = models.CharField(max_length=200)
  47. slug = models.SlugField(max_length=220, unique=False, blank=True)
  48. category = TreeForeignKey(PostCategory, on_delete=models.PROTECT, related_name="posts")
  49. author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="posts")
  50. excerpt = models.TextField(blank=True)
  51. feature_image = models.ImageField(upload_to="posts/feature/%Y/%m/%d/", null=True, blank=True)
  52. # Markdown content source + auto-rendered HTML
  53. content = MarkdownField(rendered_field="content_html", validator=VALIDATOR_STANDARD)
  54. content_html = RenderedMarkdownField(blank=True, null=True)
  55. status = models.CharField(max_length=12, choices=STATUS_CHOICES, default=STATUS_DRAFT)
  56. published_at = models.DateTimeField(null=True, blank=True)
  57. created_at = models.DateTimeField(auto_now_add=True)
  58. updated_at = models.DateTimeField(auto_now=True)
  59. # Optional tags, consistent with project usage
  60. tags = TaggableManager(blank=True)
  61. class Meta:
  62. ordering = ("-published_at", "-created_at", "-id")
  63. indexes = [
  64. models.Index(fields=["status", "published_at"]),
  65. models.Index(fields=["slug"]),
  66. ]
  67. unique_together = (("organization", "slug"),)
  68. def save(self, *args, **kwargs):
  69. if not self.slug and self.title:
  70. base = slugify(self.title)[:220].strip('-')
  71. slug = base or "post"
  72. # Ensure uniqueness within organization by appending -2, -3, ... if needed
  73. i = 2
  74. qs = Post.objects
  75. if self.organization_id:
  76. qs = qs.filter(organization_id=self.organization_id)
  77. while qs.filter(slug=slug).exclude(pk=self.pk).exists():
  78. suffix = f"-{i}"
  79. slug = (base[: 220 - len(suffix)] + suffix).strip('-') or f"post-{i}"
  80. i += 1
  81. self.slug = slug
  82. if self.status == self.STATUS_PUBLISHED and not self.published_at:
  83. self.published_at = timezone.now()
  84. super().save(*args, **kwargs)
  85. def __str__(self) -> str: # pragma: no cover - trivial
  86. return self.title
  87. # --- Demo data helpers -------------------------------------------------------
  88. def create_demo_data(org: Optional[Organization] = None) -> dict:
  89. """Create demo categories and posts for CMS.
  90. Idempotent: safe to call multiple times. Returns a summary counts dict.
  91. """
  92. created = {"categories": 0, "posts": 0}
  93. # Categories
  94. cat_specs = [
  95. {"name": "News", "description": "Company news and announcements"},
  96. {"name": "Guides", "description": "How-to guides and tutorials"},
  97. {"name": "Updates", "description": "Product updates and release notes"},
  98. ]
  99. cats: dict[str, PostCategory] = {}
  100. for spec in cat_specs:
  101. slug = slugify(spec["name"])[:140]
  102. lookup = {"slug": slug}
  103. if org is not None:
  104. lookup["organization"] = org
  105. obj, was_created = PostCategory.objects.get_or_create(
  106. **lookup,
  107. defaults={
  108. "name": spec["name"],
  109. "description": spec.get("description", ""),
  110. "organization": org,
  111. },
  112. )
  113. if was_created:
  114. created["categories"] += 1
  115. cats[obj.name] = obj
  116. # Choose an author if available
  117. try:
  118. author = User.objects.filter(is_staff=True).order_by("id").first() or User.objects.order_by("id").first()
  119. except Exception:
  120. author = None
  121. # Posts
  122. posts_specs = [
  123. {
  124. "title": "Welcome to Ecoloop",
  125. "category": cats.get("News"),
  126. "excerpt": "Introducing our recycling operations platform.",
  127. "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.",
  128. "status": Post.STATUS_PUBLISHED,
  129. "tags": ["ecoloop", "news"],
  130. },
  131. {
  132. "title": "Getting Started Guide",
  133. "category": cats.get("Guides"),
  134. "excerpt": "How to create materials, customers, and pickups.",
  135. "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.",
  136. "status": Post.STATUS_PUBLISHED,
  137. "tags": ["guide", "howto"],
  138. },
  139. {
  140. "title": "September Release Notes",
  141. "category": cats.get("Updates"),
  142. "excerpt": "Highlights from the latest release.",
  143. "content": "# Release Notes\n\n- New scrap listings and bids\n- Auto award expired listings\n- Admin UI for pickups and invoices",
  144. "status": Post.STATUS_DRAFT,
  145. "tags": ["updates", "releases"],
  146. },
  147. ]
  148. for spec in posts_specs:
  149. base_slug = slugify(spec["title"])[:220]
  150. lookup = {"slug": base_slug}
  151. if org is not None:
  152. lookup["organization"] = org
  153. # Normalize multiline markdown content to avoid code-block wrapping
  154. raw_content = spec.get("content", "")
  155. content_md = dedent(raw_content).strip()
  156. excerpt_text = spec.get("excerpt", "")
  157. if isinstance(excerpt_text, str):
  158. excerpt_text = dedent(excerpt_text).strip()
  159. post, was_created = Post.objects.get_or_create(
  160. **lookup,
  161. defaults={
  162. "title": spec["title"],
  163. "category": spec.get("category") or next(iter(cats.values())),
  164. "author": author,
  165. "excerpt": excerpt_text,
  166. "content": content_md,
  167. "status": spec.get("status", Post.STATUS_DRAFT),
  168. "organization": org,
  169. },
  170. )
  171. if was_created:
  172. created["posts"] += 1
  173. try:
  174. tags = spec.get("tags") or []
  175. if tags:
  176. post.tags.set(tags)
  177. except Exception:
  178. pass
  179. return created