digitalafarin-cms 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. digitalafarin_cms/__init__.py +20 -0
  2. digitalafarin_cms/apps/__init__.py +0 -0
  3. digitalafarin_cms/apps/audit/__init__.py +0 -0
  4. digitalafarin_cms/apps/audit/admin.py +3 -0
  5. digitalafarin_cms/apps/audit/apps.py +5 -0
  6. digitalafarin_cms/apps/audit/migrations/0001_initial.py +11 -0
  7. digitalafarin_cms/apps/audit/migrations/__init__.py +0 -0
  8. digitalafarin_cms/apps/audit/models.py +23 -0
  9. digitalafarin_cms/apps/audit/serializers.py +7 -0
  10. digitalafarin_cms/apps/audit/tasks.py +20 -0
  11. digitalafarin_cms/apps/audit/urls.py +3 -0
  12. digitalafarin_cms/apps/audit/views.py +32 -0
  13. digitalafarin_cms/apps/common/__init__.py +0 -0
  14. digitalafarin_cms/apps/common/apps.py +5 -0
  15. digitalafarin_cms/apps/common/models.py +9 -0
  16. digitalafarin_cms/apps/common/tenancy.py +158 -0
  17. digitalafarin_cms/apps/content/__init__.py +0 -0
  18. digitalafarin_cms/apps/content/admin.py +3 -0
  19. digitalafarin_cms/apps/content/apps.py +5 -0
  20. digitalafarin_cms/apps/content/management/__init__.py +0 -0
  21. digitalafarin_cms/apps/content/management/commands/__init__.py +0 -0
  22. digitalafarin_cms/apps/content/management/commands/seed_demo.py +27 -0
  23. digitalafarin_cms/apps/content/migrations/0001_initial.py +18 -0
  24. digitalafarin_cms/apps/content/migrations/__init__.py +0 -0
  25. digitalafarin_cms/apps/content/models.py +91 -0
  26. digitalafarin_cms/apps/content/serializers.py +65 -0
  27. digitalafarin_cms/apps/content/services.py +10 -0
  28. digitalafarin_cms/apps/content/tasks.py +12 -0
  29. digitalafarin_cms/apps/content/urls.py +5 -0
  30. digitalafarin_cms/apps/content/views.py +186 -0
  31. digitalafarin_cms/apps/integrations/__init__.py +0 -0
  32. digitalafarin_cms/apps/integrations/admin.py +3 -0
  33. digitalafarin_cms/apps/integrations/apps.py +5 -0
  34. digitalafarin_cms/apps/integrations/migrations/0001_initial.py +8 -0
  35. digitalafarin_cms/apps/integrations/migrations/__init__.py +0 -0
  36. digitalafarin_cms/apps/integrations/models.py +12 -0
  37. digitalafarin_cms/apps/integrations/serializers.py +4 -0
  38. digitalafarin_cms/apps/integrations/urls.py +3 -0
  39. digitalafarin_cms/apps/integrations/views.py +15 -0
  40. digitalafarin_cms/apps/media_library/__init__.py +0 -0
  41. digitalafarin_cms/apps/media_library/admin.py +3 -0
  42. digitalafarin_cms/apps/media_library/apps.py +5 -0
  43. digitalafarin_cms/apps/media_library/migrations/0001_initial.py +9 -0
  44. digitalafarin_cms/apps/media_library/migrations/__init__.py +0 -0
  45. digitalafarin_cms/apps/media_library/models.py +17 -0
  46. digitalafarin_cms/apps/media_library/serializers.py +8 -0
  47. digitalafarin_cms/apps/media_library/urls.py +3 -0
  48. digitalafarin_cms/apps/media_library/views.py +25 -0
  49. digitalafarin_cms/apps/seo/__init__.py +0 -0
  50. digitalafarin_cms/apps/seo/admin.py +3 -0
  51. digitalafarin_cms/apps/seo/apps.py +5 -0
  52. digitalafarin_cms/apps/seo/migrations/0001_initial.py +16 -0
  53. digitalafarin_cms/apps/seo/migrations/__init__.py +0 -0
  54. digitalafarin_cms/apps/seo/models.py +71 -0
  55. digitalafarin_cms/apps/seo/serializers.py +77 -0
  56. digitalafarin_cms/apps/seo/urls.py +5 -0
  57. digitalafarin_cms/apps/seo/views.py +155 -0
  58. digitalafarin_cms/apps/sites/__init__.py +0 -0
  59. digitalafarin_cms/apps/sites/admin.py +3 -0
  60. digitalafarin_cms/apps/sites/apps.py +5 -0
  61. digitalafarin_cms/apps/sites/migrations/0001_initial.py +13 -0
  62. digitalafarin_cms/apps/sites/migrations/__init__.py +0 -0
  63. digitalafarin_cms/apps/sites/models.py +28 -0
  64. digitalafarin_cms/apps/sites/serializers.py +10 -0
  65. digitalafarin_cms/apps/sites/urls.py +4 -0
  66. digitalafarin_cms/apps/sites/views.py +65 -0
  67. digitalafarin_cms/celery.py +8 -0
  68. digitalafarin_cms/settings.py +45 -0
  69. digitalafarin_cms/urls.py +13 -0
  70. digitalafarin_cms-0.2.1.dist-info/METADATA +91 -0
  71. digitalafarin_cms-0.2.1.dist-info/RECORD +74 -0
  72. digitalafarin_cms-0.2.1.dist-info/WHEEL +5 -0
  73. digitalafarin_cms-0.2.1.dist-info/licenses/LICENSE +202 -0
  74. digitalafarin_cms-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,20 @@
1
+ """DigitalAfarin Headless CMS + SEO for Django/DRF."""
2
+
3
+ __version__ = "0.2.1"
4
+
5
+ DJANGO_APPS = (
6
+ "digitalafarin_cms.apps.common",
7
+ "digitalafarin_cms.apps.sites",
8
+ "digitalafarin_cms.apps.content",
9
+ "digitalafarin_cms.apps.seo",
10
+ "digitalafarin_cms.apps.media_library",
11
+ "digitalafarin_cms.apps.audit",
12
+ "digitalafarin_cms.apps.integrations",
13
+ )
14
+
15
+ REQUIRED_DJANGO_APPS = (
16
+ "rest_framework",
17
+ "django_filters",
18
+ )
19
+
20
+ ALL_DJANGO_APPS = REQUIRED_DJANGO_APPS + DJANGO_APPS
File without changes
File without changes
@@ -0,0 +1,3 @@
1
+ from django.contrib import admin
2
+ from .models import AuditRun, AuditIssue
3
+ admin.site.register([AuditRun,AuditIssue])
@@ -0,0 +1,5 @@
1
+ from django.apps import AppConfig
2
+ class AuditConfig(AppConfig):
3
+ default_auto_field="django.db.models.BigAutoField"
4
+ name="digitalafarin_cms.apps.audit"
5
+ label = "digitalafarin_cms_audit"
@@ -0,0 +1,11 @@
1
+ import uuid
2
+ from django.db import migrations, models
3
+ import django.db.models.deletion
4
+
5
+ class Migration(migrations.Migration):
6
+ initial=True
7
+ dependencies=[("digitalafarin_cms_sites","0001_initial")]
8
+ operations=[
9
+ migrations.CreateModel(name="AuditRun",fields=[("id",models.UUIDField(default=uuid.uuid4,editable=False,primary_key=True,serialize=False)),("created_at",models.DateTimeField(auto_now_add=True)),("updated_at",models.DateTimeField(auto_now=True)),("status",models.CharField(choices=[("queued","Queued"),("running","Running"),("done","Done"),("failed","Failed")],default="queued",max_length=20)),("started_at",models.DateTimeField(blank=True,null=True)),("finished_at",models.DateTimeField(blank=True,null=True)),("pages_crawled",models.PositiveIntegerField(default=0)),("health_score",models.PositiveSmallIntegerField(default=0)),("summary",models.JSONField(blank=True,default=dict)),("site",models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,related_name="audit_runs",to="digitalafarin_cms_sites.site"))]),
10
+ migrations.CreateModel(name="AuditIssue",fields=[("id",models.UUIDField(default=uuid.uuid4,editable=False,primary_key=True,serialize=False)),("created_at",models.DateTimeField(auto_now_add=True)),("updated_at",models.DateTimeField(auto_now=True)),("url",models.URLField(max_length=1000)),("code",models.CharField(max_length=80)),("severity",models.CharField(choices=[("error","Error"),("warning","Warning"),("notice","Notice")],max_length=20)),("title",models.CharField(max_length=255)),("details",models.JSONField(blank=True,default=dict)),("is_resolved",models.BooleanField(default=False)),("run",models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,related_name="issues",to="digitalafarin_cms_audit.auditrun"))])
11
+ ]
File without changes
@@ -0,0 +1,23 @@
1
+ from django.db import models
2
+ from digitalafarin_cms.apps.common.models import UUIDTimeStampedModel
3
+ from digitalafarin_cms.apps.sites.models import Site
4
+
5
+ class AuditRun(UUIDTimeStampedModel):
6
+ class Status(models.TextChoices): QUEUED="queued","Queued"; RUNNING="running","Running"; DONE="done","Done"; FAILED="failed","Failed"
7
+ site=models.ForeignKey(Site,on_delete=models.CASCADE,related_name="audit_runs")
8
+ status=models.CharField(max_length=20,choices=Status.choices,default=Status.QUEUED)
9
+ started_at=models.DateTimeField(null=True,blank=True)
10
+ finished_at=models.DateTimeField(null=True,blank=True)
11
+ pages_crawled=models.PositiveIntegerField(default=0)
12
+ health_score=models.PositiveSmallIntegerField(default=0)
13
+ summary=models.JSONField(default=dict,blank=True)
14
+
15
+ class AuditIssue(UUIDTimeStampedModel):
16
+ class Severity(models.TextChoices): ERROR="error","Error"; WARNING="warning","Warning"; NOTICE="notice","Notice"
17
+ run=models.ForeignKey(AuditRun,on_delete=models.CASCADE,related_name="issues")
18
+ url=models.URLField(max_length=1000)
19
+ code=models.CharField(max_length=80)
20
+ severity=models.CharField(max_length=20,choices=Severity.choices)
21
+ title=models.CharField(max_length=255)
22
+ details=models.JSONField(default=dict,blank=True)
23
+ is_resolved=models.BooleanField(default=False)
@@ -0,0 +1,7 @@
1
+ from rest_framework import serializers
2
+ from .models import AuditRun, AuditIssue
3
+ class AuditRunSerializer(serializers.ModelSerializer):
4
+ issue_count=serializers.IntegerField(source="issues.count",read_only=True)
5
+ class Meta: model=AuditRun; fields="__all__"
6
+ class AuditIssueSerializer(serializers.ModelSerializer):
7
+ class Meta: model=AuditIssue; fields="__all__"
@@ -0,0 +1,20 @@
1
+ from celery import shared_task
2
+ from django.utils import timezone
3
+ from .models import AuditRun
4
+
5
+ @shared_task
6
+ def run_audit(audit_run_id):
7
+ run=AuditRun.objects.get(id=audit_run_id)
8
+ run.status=AuditRun.Status.RUNNING; run.started_at=timezone.now(); run.save(update_fields=["status","started_at","updated_at"])
9
+ # V1 worker foundation: advanced HTTP crawling is intentionally a separate expansion module.
10
+ entries=run.site.entries.filter(status="published")
11
+ issues=[]
12
+ for entry in entries:
13
+ if not hasattr(entry,"seo_meta"):
14
+ issues.append({"url":f"https://{run.site.domain}{entry.path}","code":"missing_seo_meta","severity":"warning","title":"Missing SEO metadata"})
15
+ from .models import AuditIssue
16
+ AuditIssue.objects.bulk_create([AuditIssue(run=run,**x) for x in issues])
17
+ count=entries.count(); score=max(0,100-min(100,len(issues)*10))
18
+ run.status=AuditRun.Status.DONE; run.finished_at=timezone.now(); run.pages_crawled=count; run.health_score=score; run.summary={"issues":len(issues)}
19
+ run.save(update_fields=["status","finished_at","pages_crawled","health_score","summary","updated_at"])
20
+ return {"pages":count,"issues":len(issues),"score":score}
@@ -0,0 +1,3 @@
1
+ from rest_framework.routers import DefaultRouter
2
+ from .views import AuditRunViewSet, AuditIssueViewSet
3
+ router=DefaultRouter(); router.register("runs",AuditRunViewSet); router.register("issues",AuditIssueViewSet); urlpatterns=router.urls
@@ -0,0 +1,32 @@
1
+ from rest_framework import viewsets
2
+ from rest_framework.decorators import action
3
+ from rest_framework.permissions import IsAuthenticated
4
+ from rest_framework.response import Response
5
+
6
+ from digitalafarin_cms.apps.common.tenancy import TenantScopedViewSetMixin
7
+ from .models import AuditIssue, AuditRun
8
+ from .serializers import AuditIssueSerializer, AuditRunSerializer
9
+ from .tasks import run_audit
10
+
11
+
12
+ class AuditRunViewSet(TenantScopedViewSetMixin, viewsets.ModelViewSet):
13
+ permission_classes = [IsAuthenticated]
14
+ queryset = AuditRun.objects.select_related("site").all().order_by("-created_at")
15
+ serializer_class = AuditRunSerializer
16
+ filterset_fields = ["site", "status"]
17
+ tenant_filter = "site__organization_id"
18
+
19
+ @action(detail=True, methods=["post"])
20
+ def execute(self, request, pk=None):
21
+ run = self.get_object()
22
+ run_audit.delay(str(run.id))
23
+ return Response({"queued": True, "audit_run": str(run.id)})
24
+
25
+
26
+ class AuditIssueViewSet(TenantScopedViewSetMixin, viewsets.ModelViewSet):
27
+ permission_classes = [IsAuthenticated]
28
+ queryset = AuditIssue.objects.select_related("run", "run__site").all()
29
+ serializer_class = AuditIssueSerializer
30
+ filterset_fields = ["run", "severity", "code", "is_resolved"]
31
+ search_fields = ["url", "title", "code"]
32
+ tenant_filter = "run__site__organization_id"
File without changes
@@ -0,0 +1,5 @@
1
+ from django.apps import AppConfig
2
+ class CommonConfig(AppConfig):
3
+ default_auto_field = "django.db.models.BigAutoField"
4
+ name = "digitalafarin_cms.apps.common"
5
+ label = "digitalafarin_cms_common"
@@ -0,0 +1,9 @@
1
+ import uuid
2
+ from django.db import models
3
+
4
+ class UUIDTimeStampedModel(models.Model):
5
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
6
+ created_at = models.DateTimeField(auto_now_add=True)
7
+ updated_at = models.DateTimeField(auto_now=True)
8
+ class Meta:
9
+ abstract = True
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+
5
+ from django.core.exceptions import PermissionDenied
6
+ from django.db.models import Model
7
+
8
+
9
+ def allowed_organization_ids(user):
10
+ """Return None for superusers, otherwise the organization ids visible to the user."""
11
+ if not getattr(user, "is_authenticated", False):
12
+ return []
13
+ if getattr(user, "is_superuser", False):
14
+ return None
15
+
16
+ from digitalafarin_cms.apps.sites.models import Membership
17
+
18
+ return list(
19
+ Membership.objects.filter(user=user, organization__is_active=True).values_list(
20
+ "organization_id", flat=True
21
+ )
22
+ )
23
+
24
+
25
+ def ensure_organization_access(user, organization_id) -> None:
26
+ if organization_id is None or getattr(user, "is_superuser", False):
27
+ return
28
+ allowed = allowed_organization_ids(user)
29
+ if organization_id not in allowed:
30
+ raise PermissionDenied("You do not have access to this organization.")
31
+
32
+
33
+ def ensure_organization_write_access(user, organization_id, allowed_roles=None) -> None:
34
+ if organization_id is None or getattr(user, "is_superuser", False):
35
+ return
36
+
37
+ from digitalafarin_cms.apps.sites.models import Membership
38
+
39
+ roles = allowed_roles or (
40
+ Membership.Role.OWNER,
41
+ Membership.Role.ADMIN,
42
+ Membership.Role.SEO,
43
+ Membership.Role.EDITOR,
44
+ Membership.Role.WRITER,
45
+ )
46
+ if not Membership.objects.filter(
47
+ organization_id=organization_id, user=user, role__in=roles
48
+ ).exists():
49
+ raise PermissionDenied("Your role does not allow changes in this organization.")
50
+
51
+
52
+ def organization_id_for_instance(value: Model | None, _seen: set[int] | None = None):
53
+ """Best-effort organization resolution for CMS model instances.
54
+
55
+ This intentionally follows only a small list of known CMS relationship names to avoid
56
+ surprising arbitrary ORM traversal.
57
+ """
58
+ if value is None or not isinstance(value, Model):
59
+ return None
60
+
61
+ seen = _seen or set()
62
+ marker = id(value)
63
+ if marker in seen:
64
+ return None
65
+ seen.add(marker)
66
+
67
+ model_name = value.__class__.__name__
68
+ if model_name == "Organization":
69
+ return value.pk
70
+
71
+ organization_id = getattr(value, "organization_id", None)
72
+ if organization_id is not None:
73
+ return organization_id
74
+
75
+ relationship_names = (
76
+ "site",
77
+ "entry",
78
+ "keyword",
79
+ "cluster",
80
+ "source_entry",
81
+ "target_entry",
82
+ "run",
83
+ "menu",
84
+ "content_type",
85
+ "parent",
86
+ )
87
+ for name in relationship_names:
88
+ try:
89
+ related = getattr(value, name, None)
90
+ except value.__class__.DoesNotExist:
91
+ related = None
92
+ organization_id = organization_id_for_instance(related, seen)
93
+ if organization_id is not None:
94
+ return organization_id
95
+ return None
96
+
97
+
98
+ def _iter_model_values(value):
99
+ if isinstance(value, Model):
100
+ yield value
101
+ return
102
+ if isinstance(value, dict):
103
+ for nested in value.values():
104
+ yield from _iter_model_values(nested)
105
+ return
106
+ if isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
107
+ for nested in value:
108
+ yield from _iter_model_values(nested)
109
+
110
+
111
+ class TenantScopedViewSetMixin:
112
+ """Scope reads to memberships and protect unsafe writes by organization role."""
113
+
114
+ tenant_filter: str | None = None
115
+ write_roles = None
116
+
117
+ def get_queryset(self):
118
+ queryset = super().get_queryset()
119
+ user = self.request.user
120
+ allowed = allowed_organization_ids(user)
121
+ if allowed is None:
122
+ return queryset
123
+ if not self.tenant_filter or not allowed:
124
+ return queryset.none()
125
+ return queryset.filter(**{f"{self.tenant_filter}__in": allowed})
126
+
127
+ def get_object(self):
128
+ instance = super().get_object()
129
+ if self.request.method not in {"GET", "HEAD", "OPTIONS"}:
130
+ ensure_organization_write_access(
131
+ self.request.user,
132
+ organization_id_for_instance(instance),
133
+ self.write_roles,
134
+ )
135
+ return instance
136
+
137
+ def validate_tenant_serializer(self, serializer, *, require_write=False) -> None:
138
+ organization_ids = set()
139
+ for value in serializer.validated_data.values():
140
+ for instance in _iter_model_values(value):
141
+ organization_id = organization_id_for_instance(instance)
142
+ ensure_organization_access(self.request.user, organization_id)
143
+ if organization_id is not None:
144
+ organization_ids.add(organization_id)
145
+
146
+ if require_write:
147
+ for organization_id in organization_ids:
148
+ ensure_organization_write_access(
149
+ self.request.user, organization_id, self.write_roles
150
+ )
151
+
152
+ def perform_create(self, serializer):
153
+ self.validate_tenant_serializer(serializer, require_write=True)
154
+ serializer.save()
155
+
156
+ def perform_update(self, serializer):
157
+ self.validate_tenant_serializer(serializer, require_write=True)
158
+ serializer.save()
File without changes
@@ -0,0 +1,3 @@
1
+ from django.contrib import admin
2
+ from .models import ContentTypeDefinition, ContentEntry, ContentRevision, Category, Tag, ReusableBlock, Menu, MenuItem
3
+ admin.site.register([ContentTypeDefinition,ContentEntry,ContentRevision,Category,Tag,ReusableBlock,Menu,MenuItem])
@@ -0,0 +1,5 @@
1
+ from django.apps import AppConfig
2
+ class ContentConfig(AppConfig):
3
+ default_auto_field="django.db.models.BigAutoField"
4
+ name="digitalafarin_cms.apps.content"
5
+ label = "digitalafarin_cms_content"
File without changes
@@ -0,0 +1,27 @@
1
+ from django.core.management.base import BaseCommand
2
+ from django.contrib.auth import get_user_model
3
+ from digitalafarin_cms.apps.sites.models import Organization, Site, Membership
4
+ from digitalafarin_cms.apps.content.models import ContentTypeDefinition, ContentEntry, Category, Menu, MenuItem
5
+ from digitalafarin_cms.apps.seo.models import SeoMeta, SchemaMarkup, KeywordCluster, Keyword, KeywordMapping
6
+
7
+ class Command(BaseCommand):
8
+ help="Create development demo data"
9
+ def handle(self,*args,**options):
10
+ User=get_user_model()
11
+ user,created=User.objects.get_or_create(username="admin",defaults={"email":"admin@example.com","is_staff":True,"is_superuser":True})
12
+ if created or not user.has_usable_password():
13
+ user.set_password("admin12345"); user.is_staff=True; user.is_superuser=True; user.save()
14
+ org,_=Organization.objects.get_or_create(slug="digitalafarin",defaults={"name":"DigitalAfarin"})
15
+ site,_=Site.objects.get_or_create(domain="demo.local",defaults={"organization":org,"name":"Demo SEO Site","default_language":"fa"})
16
+ Membership.objects.get_or_create(organization=org,user=user,defaults={"role":"owner"})
17
+ page_type,_=ContentTypeDefinition.objects.get_or_create(site=site,slug="page",defaults={"name":"Page","schema":{"fields":[]}})
18
+ post_type,_=ContentTypeDefinition.objects.get_or_create(site=site,slug="post",defaults={"name":"Post","schema":{"fields":[]}})
19
+ home,_=ContentEntry.objects.get_or_create(site=site,path="/",defaults={"content_type":page_type,"title":"DigitalAfarin SEO CMS","slug":"home","status":"published","author":user,"blocks":[{"id":"hero-1","type":"hero","data":{"title":"Headless SEO CMS","subtitle":"Django REST + Next.js"}},{"id":"p-1","type":"paragraph","data":{"text":"This page is served from the CMS resolver API."}}]})
20
+ SeoMeta.objects.get_or_create(entry=home,defaults={"title":"Headless SEO CMS | DigitalAfarin","description":"Multi-site content and SEO management platform for Next.js.","focus_keyword":"headless seo cms","seo_score":80})
21
+ SchemaMarkup.objects.get_or_create(entry=home,schema_type="WebPage",defaults={"data":{"@context":"https://schema.org","@type":"WebPage","name":home.title}})
22
+ cluster,_=KeywordCluster.objects.get_or_create(site=site,name="Headless CMS",defaults={"pillar_entry":home,"intent":"commercial"})
23
+ kw,_=Keyword.objects.get_or_create(site=site,phrase="headless seo cms",defaults={"cluster":cluster,"intent":"commercial","priority":90})
24
+ KeywordMapping.objects.get_or_create(keyword=kw,entry=home,defaults={"is_primary":True})
25
+ menu,_=Menu.objects.get_or_create(site=site,key="main",defaults={"name":"Main Menu"})
26
+ MenuItem.objects.get_or_create(menu=menu,label="Home",url="/",defaults={"sort_order":0})
27
+ self.stdout.write(self.style.SUCCESS("Demo data ready: admin / admin12345; site=demo.local"))
@@ -0,0 +1,18 @@
1
+ import uuid
2
+ from django.conf import settings
3
+ from django.db import migrations, models
4
+ import django.db.models.deletion
5
+
6
+ class Migration(migrations.Migration):
7
+ initial=True
8
+ dependencies=[("digitalafarin_cms_sites","0001_initial"),migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
9
+ operations=[
10
+ migrations.CreateModel(name="Category",fields=[("id",models.UUIDField(default=uuid.uuid4,editable=False,primary_key=True,serialize=False)),("created_at",models.DateTimeField(auto_now_add=True)),("updated_at",models.DateTimeField(auto_now=True)),("name",models.CharField(max_length=160)),("slug",models.SlugField(max_length=160)),("parent",models.ForeignKey(blank=True,null=True,on_delete=django.db.models.deletion.SET_NULL,related_name="children",to="digitalafarin_cms_content.category")),("site",models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,related_name="categories",to="digitalafarin_cms_sites.site"))],options={"constraints":[models.UniqueConstraint(fields=("site","slug"),name="unique_category_site_slug")]}),
11
+ migrations.CreateModel(name="ContentTypeDefinition",fields=[("id",models.UUIDField(default=uuid.uuid4,editable=False,primary_key=True,serialize=False)),("created_at",models.DateTimeField(auto_now_add=True)),("updated_at",models.DateTimeField(auto_now=True)),("name",models.CharField(max_length=120)),("slug",models.SlugField(max_length=120)),("schema",models.JSONField(blank=True,default=dict,help_text="Custom field definitions")),("icon",models.CharField(blank=True,max_length=50)),("is_public",models.BooleanField(default=True)),("site",models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,related_name="content_types",to="digitalafarin_cms_sites.site"))],options={"constraints":[models.UniqueConstraint(fields=("site","slug"),name="unique_content_type_site_slug")]}),
12
+ migrations.CreateModel(name="Menu",fields=[("id",models.UUIDField(default=uuid.uuid4,editable=False,primary_key=True,serialize=False)),("created_at",models.DateTimeField(auto_now_add=True)),("updated_at",models.DateTimeField(auto_now=True)),("name",models.CharField(max_length=120)),("key",models.SlugField(max_length=120)),("site",models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,related_name="menus",to="digitalafarin_cms_sites.site"))],options={"constraints":[models.UniqueConstraint(fields=("site","key"),name="unique_menu_site_key")]}),
13
+ migrations.CreateModel(name="ReusableBlock",fields=[("id",models.UUIDField(default=uuid.uuid4,editable=False,primary_key=True,serialize=False)),("created_at",models.DateTimeField(auto_now_add=True)),("updated_at",models.DateTimeField(auto_now=True)),("name",models.CharField(max_length=160)),("key",models.SlugField(max_length=160)),("block",models.JSONField(default=dict)),("site",models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,related_name="reusable_blocks",to="digitalafarin_cms_sites.site"))],options={"constraints":[models.UniqueConstraint(fields=("site","key"),name="unique_reusable_block_key")]}),
14
+ migrations.CreateModel(name="Tag",fields=[("id",models.UUIDField(default=uuid.uuid4,editable=False,primary_key=True,serialize=False)),("created_at",models.DateTimeField(auto_now_add=True)),("updated_at",models.DateTimeField(auto_now=True)),("name",models.CharField(max_length=160)),("slug",models.SlugField(max_length=160)),("site",models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,related_name="tags",to="digitalafarin_cms_sites.site"))],options={"constraints":[models.UniqueConstraint(fields=("site","slug"),name="unique_tag_site_slug")]}),
15
+ migrations.CreateModel(name="ContentEntry",fields=[("id",models.UUIDField(default=uuid.uuid4,editable=False,primary_key=True,serialize=False)),("created_at",models.DateTimeField(auto_now_add=True)),("updated_at",models.DateTimeField(auto_now=True)),("title",models.CharField(max_length=255)),("slug",models.SlugField(max_length=255)),("path",models.CharField(help_text="Canonical site path, e.g. /blog/example/",max_length=500)),("excerpt",models.TextField(blank=True)),("blocks",models.JSONField(blank=True,default=list)),("custom_fields",models.JSONField(blank=True,default=dict)),("status",models.CharField(choices=[("draft","Draft"),("review","Review"),("scheduled","Scheduled"),("published","Published"),("archived","Archived")],default="draft",max_length=20)),("published_at",models.DateTimeField(blank=True,null=True)),("scheduled_at",models.DateTimeField(blank=True,null=True)),("is_featured",models.BooleanField(default=False)),("author",models.ForeignKey(blank=True,null=True,on_delete=django.db.models.deletion.SET_NULL,related_name="cms_entries",to=settings.AUTH_USER_MODEL)),("categories",models.ManyToManyField(blank=True,related_name="entries",to="digitalafarin_cms_content.category")),("content_type",models.ForeignKey(on_delete=django.db.models.deletion.PROTECT,related_name="entries",to="digitalafarin_cms_content.contenttypedefinition")),("parent",models.ForeignKey(blank=True,null=True,on_delete=django.db.models.deletion.SET_NULL,related_name="children",to="digitalafarin_cms_content.contententry")),("site",models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,related_name="entries",to="digitalafarin_cms_sites.site")),("tags",models.ManyToManyField(blank=True,related_name="entries",to="digitalafarin_cms_content.tag"))],options={"ordering":["-published_at","-created_at"],"constraints":[models.UniqueConstraint(fields=("site","path"),name="unique_entry_site_path")]}),
16
+ migrations.CreateModel(name="ContentRevision",fields=[("id",models.UUIDField(default=uuid.uuid4,editable=False,primary_key=True,serialize=False)),("created_at",models.DateTimeField(auto_now_add=True)),("updated_at",models.DateTimeField(auto_now=True)),("number",models.PositiveIntegerField()),("snapshot",models.JSONField(default=dict)),("note",models.CharField(blank=True,max_length=255)),("created_by",models.ForeignKey(blank=True,null=True,on_delete=django.db.models.deletion.SET_NULL,to=settings.AUTH_USER_MODEL)),("entry",models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,related_name="revisions",to="digitalafarin_cms_content.contententry"))],options={"ordering":["-number"],"constraints":[models.UniqueConstraint(fields=("entry","number"),name="unique_revision_number")]}),
17
+ migrations.CreateModel(name="MenuItem",fields=[("id",models.UUIDField(default=uuid.uuid4,editable=False,primary_key=True,serialize=False)),("created_at",models.DateTimeField(auto_now_add=True)),("updated_at",models.DateTimeField(auto_now=True)),("label",models.CharField(max_length=160)),("url",models.CharField(max_length=500)),("sort_order",models.PositiveIntegerField(default=0)),("is_external",models.BooleanField(default=False)),("menu",models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,related_name="items",to="digitalafarin_cms_content.menu")),("parent",models.ForeignKey(blank=True,null=True,on_delete=django.db.models.deletion.CASCADE,related_name="children",to="digitalafarin_cms_content.menuitem"))],options={"ordering":["sort_order","created_at"]})
18
+ ]
File without changes
@@ -0,0 +1,91 @@
1
+ from django.conf import settings
2
+ from django.db import models
3
+ from django.utils import timezone
4
+ from digitalafarin_cms.apps.common.models import UUIDTimeStampedModel
5
+ from digitalafarin_cms.apps.sites.models import Site
6
+
7
+ class ContentTypeDefinition(UUIDTimeStampedModel):
8
+ site=models.ForeignKey(Site,on_delete=models.CASCADE,related_name="content_types")
9
+ name=models.CharField(max_length=120)
10
+ slug=models.SlugField(max_length=120)
11
+ schema=models.JSONField(default=dict,blank=True, help_text="Custom field definitions")
12
+ icon=models.CharField(max_length=50,blank=True)
13
+ is_public=models.BooleanField(default=True)
14
+ class Meta: constraints=[models.UniqueConstraint(fields=["site","slug"],name="unique_content_type_site_slug")]
15
+ def __str__(self): return f"{self.site.domain}:{self.slug}"
16
+
17
+ class Category(UUIDTimeStampedModel):
18
+ site=models.ForeignKey(Site,on_delete=models.CASCADE,related_name="categories")
19
+ name=models.CharField(max_length=160)
20
+ slug=models.SlugField(max_length=160)
21
+ parent=models.ForeignKey("self",null=True,blank=True,on_delete=models.SET_NULL,related_name="children")
22
+ class Meta: constraints=[models.UniqueConstraint(fields=["site","slug"],name="unique_category_site_slug")]
23
+ def __str__(self): return self.name
24
+
25
+ class Tag(UUIDTimeStampedModel):
26
+ site=models.ForeignKey(Site,on_delete=models.CASCADE,related_name="tags")
27
+ name=models.CharField(max_length=160)
28
+ slug=models.SlugField(max_length=160)
29
+ class Meta: constraints=[models.UniqueConstraint(fields=["site","slug"],name="unique_tag_site_slug")]
30
+ def __str__(self): return self.name
31
+
32
+ class ContentEntry(UUIDTimeStampedModel):
33
+ class Status(models.TextChoices):
34
+ DRAFT="draft","Draft"; REVIEW="review","Review"; SCHEDULED="scheduled","Scheduled"; PUBLISHED="published","Published"; ARCHIVED="archived","Archived"
35
+ site=models.ForeignKey(Site,on_delete=models.CASCADE,related_name="entries")
36
+ content_type=models.ForeignKey(ContentTypeDefinition,on_delete=models.PROTECT,related_name="entries")
37
+ title=models.CharField(max_length=255)
38
+ slug=models.SlugField(max_length=255)
39
+ path=models.CharField(max_length=500,help_text="Canonical site path, e.g. /blog/example/")
40
+ excerpt=models.TextField(blank=True)
41
+ blocks=models.JSONField(default=list,blank=True)
42
+ custom_fields=models.JSONField(default=dict,blank=True)
43
+ status=models.CharField(max_length=20,choices=Status.choices,default=Status.DRAFT)
44
+ author=models.ForeignKey(settings.AUTH_USER_MODEL,null=True,blank=True,on_delete=models.SET_NULL,related_name="cms_entries")
45
+ categories=models.ManyToManyField(Category,blank=True,related_name="entries")
46
+ tags=models.ManyToManyField(Tag,blank=True,related_name="entries")
47
+ parent=models.ForeignKey("self",null=True,blank=True,on_delete=models.SET_NULL,related_name="children")
48
+ published_at=models.DateTimeField(null=True,blank=True)
49
+ scheduled_at=models.DateTimeField(null=True,blank=True)
50
+ is_featured=models.BooleanField(default=False)
51
+ class Meta:
52
+ constraints=[models.UniqueConstraint(fields=["site","path"],name="unique_entry_site_path")]
53
+ ordering=["-published_at","-created_at"]
54
+ def __str__(self): return self.title
55
+ def publish(self):
56
+ self.status=self.Status.PUBLISHED
57
+ self.published_at=self.published_at or timezone.now()
58
+ self.save(update_fields=["status","published_at","updated_at"])
59
+
60
+ class ContentRevision(UUIDTimeStampedModel):
61
+ entry=models.ForeignKey(ContentEntry,on_delete=models.CASCADE,related_name="revisions")
62
+ number=models.PositiveIntegerField()
63
+ snapshot=models.JSONField(default=dict)
64
+ created_by=models.ForeignKey(settings.AUTH_USER_MODEL,null=True,blank=True,on_delete=models.SET_NULL)
65
+ note=models.CharField(max_length=255,blank=True)
66
+ class Meta:
67
+ constraints=[models.UniqueConstraint(fields=["entry","number"],name="unique_revision_number")]
68
+ ordering=["-number"]
69
+
70
+ class ReusableBlock(UUIDTimeStampedModel):
71
+ site=models.ForeignKey(Site,on_delete=models.CASCADE,related_name="reusable_blocks")
72
+ name=models.CharField(max_length=160)
73
+ key=models.SlugField(max_length=160)
74
+ block=models.JSONField(default=dict)
75
+ class Meta: constraints=[models.UniqueConstraint(fields=["site","key"],name="unique_reusable_block_key")]
76
+
77
+ class Menu(UUIDTimeStampedModel):
78
+ site=models.ForeignKey(Site,on_delete=models.CASCADE,related_name="menus")
79
+ name=models.CharField(max_length=120)
80
+ key=models.SlugField(max_length=120)
81
+ class Meta: constraints=[models.UniqueConstraint(fields=["site","key"],name="unique_menu_site_key")]
82
+ def __str__(self): return self.name
83
+
84
+ class MenuItem(UUIDTimeStampedModel):
85
+ menu=models.ForeignKey(Menu,on_delete=models.CASCADE,related_name="items")
86
+ label=models.CharField(max_length=160)
87
+ url=models.CharField(max_length=500)
88
+ parent=models.ForeignKey("self",null=True,blank=True,on_delete=models.CASCADE,related_name="children")
89
+ sort_order=models.PositiveIntegerField(default=0)
90
+ is_external=models.BooleanField(default=False)
91
+ class Meta: ordering=["sort_order","created_at"]
@@ -0,0 +1,65 @@
1
+ from rest_framework import serializers
2
+ from .models import ContentTypeDefinition, ContentEntry, ContentRevision, Category, Tag, ReusableBlock, Menu, MenuItem
3
+
4
+ class ContentTypeSerializer(serializers.ModelSerializer):
5
+ class Meta: model=ContentTypeDefinition; fields="__all__"
6
+ class CategorySerializer(serializers.ModelSerializer):
7
+ class Meta: model=Category; fields="__all__"
8
+ def validate(self, attrs):
9
+ site = attrs.get("site") or getattr(self.instance, "site", None)
10
+ parent = attrs.get("parent", getattr(self.instance, "parent", None))
11
+ if site and parent and parent.site_id != site.id:
12
+ raise serializers.ValidationError({"parent": "Parent category must belong to the same site."})
13
+ return attrs
14
+ class TagSerializer(serializers.ModelSerializer):
15
+ class Meta: model=Tag; fields="__all__"
16
+ class ContentEntrySerializer(serializers.ModelSerializer):
17
+ content_type_slug=serializers.CharField(source="content_type.slug",read_only=True)
18
+ author=serializers.PrimaryKeyRelatedField(read_only=True)
19
+ author_name=serializers.CharField(source="author.username",read_only=True)
20
+ class Meta: model=ContentEntry; fields="__all__"
21
+ def validate_path(self,value):
22
+ if not value.startswith("/"): value="/"+value
23
+ if "?" in value or "#" in value: raise serializers.ValidationError("Path must not contain query strings or fragments.")
24
+ if value != "/" and not value.endswith("/"): value += "/"
25
+ return value
26
+ def validate_blocks(self,value):
27
+ if not isinstance(value,list): raise serializers.ValidationError("blocks must be a list")
28
+ for i,block in enumerate(value):
29
+ if not isinstance(block,dict) or not block.get("type") or not isinstance(block.get("data",{}),dict):
30
+ raise serializers.ValidationError(f"Invalid block at index {i}; each block requires type and data object")
31
+ return value
32
+ def validate(self,attrs):
33
+ site=attrs.get("site") or getattr(self.instance,"site",None)
34
+ ctype=attrs.get("content_type") or getattr(self.instance,"content_type",None)
35
+ parent=attrs.get("parent", getattr(self.instance,"parent",None))
36
+ categories=attrs.get("categories")
37
+ tags=attrs.get("tags")
38
+ custom=attrs.get("custom_fields", getattr(self.instance,"custom_fields",{})) or {}
39
+
40
+ if site and ctype and ctype.site_id != site.id:
41
+ raise serializers.ValidationError({"content_type":"Content type must belong to the same site."})
42
+ if site and parent and parent.site_id != site.id:
43
+ raise serializers.ValidationError({"parent":"Parent entry must belong to the same site."})
44
+ if site and categories is not None and any(item.site_id != site.id for item in categories):
45
+ raise serializers.ValidationError({"categories":"All categories must belong to the same site."})
46
+ if site and tags is not None and any(item.site_id != site.id for item in tags):
47
+ raise serializers.ValidationError({"tags":"All tags must belong to the same site."})
48
+
49
+ if ctype:
50
+ required=[x.get("key") for x in ctype.schema.get("fields",[]) if x.get("required")]
51
+ missing=[key for key in required if key and custom.get(key) in (None,"")]
52
+ if missing: raise serializers.ValidationError({"custom_fields":f"Missing required fields: {', '.join(missing)}"})
53
+ return attrs
54
+ class ContentRevisionSerializer(serializers.ModelSerializer):
55
+ class Meta: model=ContentRevision; fields="__all__"
56
+ class ReusableBlockSerializer(serializers.ModelSerializer):
57
+ class Meta: model=ReusableBlock; fields="__all__"
58
+ class MenuItemSerializer(serializers.ModelSerializer):
59
+ children=serializers.SerializerMethodField()
60
+ class Meta: model=MenuItem; fields="__all__"
61
+ def get_children(self,obj): return MenuItemSerializer(obj.children.all(),many=True).data
62
+ class MenuSerializer(serializers.ModelSerializer):
63
+ items=serializers.SerializerMethodField()
64
+ class Meta: model=Menu; fields="__all__"
65
+ def get_items(self,obj): return MenuItemSerializer(obj.items.filter(parent__isnull=True),many=True).data
@@ -0,0 +1,10 @@
1
+ from .models import ContentRevision
2
+
3
+ def create_revision(entry, user=None, note=""):
4
+ latest=entry.revisions.first()
5
+ number=(latest.number+1) if latest else 1
6
+ snapshot={
7
+ "title":entry.title,"slug":entry.slug,"path":entry.path,"excerpt":entry.excerpt,
8
+ "blocks":entry.blocks,"custom_fields":entry.custom_fields,"status":entry.status,
9
+ }
10
+ return ContentRevision.objects.create(entry=entry,number=number,snapshot=snapshot,created_by=user,note=note)
@@ -0,0 +1,12 @@
1
+ from celery import shared_task
2
+ from django.utils import timezone
3
+ from .models import ContentEntry
4
+ from .services import create_revision
5
+
6
+ @shared_task
7
+ def publish_due_entries():
8
+ due=ContentEntry.objects.filter(status=ContentEntry.Status.SCHEDULED,scheduled_at__isnull=False,scheduled_at__lte=timezone.now())
9
+ count=0
10
+ for entry in due.iterator():
11
+ entry.publish(); create_revision(entry,None,"Scheduled publication"); count += 1
12
+ return count
@@ -0,0 +1,5 @@
1
+ from django.urls import path, include
2
+ from rest_framework.routers import DefaultRouter
3
+ from .views import ContentTypeViewSet, ContentEntryViewSet, ContentRevisionViewSet, CategoryViewSet, TagViewSet, ReusableBlockViewSet, MenuViewSet, resolve_path, sitemap, robots_txt
4
+ router=DefaultRouter(); router.register("types",ContentTypeViewSet); router.register("entries",ContentEntryViewSet); router.register("revisions",ContentRevisionViewSet); router.register("categories",CategoryViewSet); router.register("tags",TagViewSet); router.register("reusable-blocks",ReusableBlockViewSet); router.register("menus",MenuViewSet)
5
+ urlpatterns=[path("resolve/",resolve_path),path("sitemap/",sitemap),path("robots/",robots_txt),path("",include(router.urls))]