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.
- digitalafarin_cms/__init__.py +20 -0
- digitalafarin_cms/apps/__init__.py +0 -0
- digitalafarin_cms/apps/audit/__init__.py +0 -0
- digitalafarin_cms/apps/audit/admin.py +3 -0
- digitalafarin_cms/apps/audit/apps.py +5 -0
- digitalafarin_cms/apps/audit/migrations/0001_initial.py +11 -0
- digitalafarin_cms/apps/audit/migrations/__init__.py +0 -0
- digitalafarin_cms/apps/audit/models.py +23 -0
- digitalafarin_cms/apps/audit/serializers.py +7 -0
- digitalafarin_cms/apps/audit/tasks.py +20 -0
- digitalafarin_cms/apps/audit/urls.py +3 -0
- digitalafarin_cms/apps/audit/views.py +32 -0
- digitalafarin_cms/apps/common/__init__.py +0 -0
- digitalafarin_cms/apps/common/apps.py +5 -0
- digitalafarin_cms/apps/common/models.py +9 -0
- digitalafarin_cms/apps/common/tenancy.py +158 -0
- digitalafarin_cms/apps/content/__init__.py +0 -0
- digitalafarin_cms/apps/content/admin.py +3 -0
- digitalafarin_cms/apps/content/apps.py +5 -0
- digitalafarin_cms/apps/content/management/__init__.py +0 -0
- digitalafarin_cms/apps/content/management/commands/__init__.py +0 -0
- digitalafarin_cms/apps/content/management/commands/seed_demo.py +27 -0
- digitalafarin_cms/apps/content/migrations/0001_initial.py +18 -0
- digitalafarin_cms/apps/content/migrations/__init__.py +0 -0
- digitalafarin_cms/apps/content/models.py +91 -0
- digitalafarin_cms/apps/content/serializers.py +65 -0
- digitalafarin_cms/apps/content/services.py +10 -0
- digitalafarin_cms/apps/content/tasks.py +12 -0
- digitalafarin_cms/apps/content/urls.py +5 -0
- digitalafarin_cms/apps/content/views.py +186 -0
- digitalafarin_cms/apps/integrations/__init__.py +0 -0
- digitalafarin_cms/apps/integrations/admin.py +3 -0
- digitalafarin_cms/apps/integrations/apps.py +5 -0
- digitalafarin_cms/apps/integrations/migrations/0001_initial.py +8 -0
- digitalafarin_cms/apps/integrations/migrations/__init__.py +0 -0
- digitalafarin_cms/apps/integrations/models.py +12 -0
- digitalafarin_cms/apps/integrations/serializers.py +4 -0
- digitalafarin_cms/apps/integrations/urls.py +3 -0
- digitalafarin_cms/apps/integrations/views.py +15 -0
- digitalafarin_cms/apps/media_library/__init__.py +0 -0
- digitalafarin_cms/apps/media_library/admin.py +3 -0
- digitalafarin_cms/apps/media_library/apps.py +5 -0
- digitalafarin_cms/apps/media_library/migrations/0001_initial.py +9 -0
- digitalafarin_cms/apps/media_library/migrations/__init__.py +0 -0
- digitalafarin_cms/apps/media_library/models.py +17 -0
- digitalafarin_cms/apps/media_library/serializers.py +8 -0
- digitalafarin_cms/apps/media_library/urls.py +3 -0
- digitalafarin_cms/apps/media_library/views.py +25 -0
- digitalafarin_cms/apps/seo/__init__.py +0 -0
- digitalafarin_cms/apps/seo/admin.py +3 -0
- digitalafarin_cms/apps/seo/apps.py +5 -0
- digitalafarin_cms/apps/seo/migrations/0001_initial.py +16 -0
- digitalafarin_cms/apps/seo/migrations/__init__.py +0 -0
- digitalafarin_cms/apps/seo/models.py +71 -0
- digitalafarin_cms/apps/seo/serializers.py +77 -0
- digitalafarin_cms/apps/seo/urls.py +5 -0
- digitalafarin_cms/apps/seo/views.py +155 -0
- digitalafarin_cms/apps/sites/__init__.py +0 -0
- digitalafarin_cms/apps/sites/admin.py +3 -0
- digitalafarin_cms/apps/sites/apps.py +5 -0
- digitalafarin_cms/apps/sites/migrations/0001_initial.py +13 -0
- digitalafarin_cms/apps/sites/migrations/__init__.py +0 -0
- digitalafarin_cms/apps/sites/models.py +28 -0
- digitalafarin_cms/apps/sites/serializers.py +10 -0
- digitalafarin_cms/apps/sites/urls.py +4 -0
- digitalafarin_cms/apps/sites/views.py +65 -0
- digitalafarin_cms/celery.py +8 -0
- digitalafarin_cms/settings.py +45 -0
- digitalafarin_cms/urls.py +13 -0
- digitalafarin_cms-0.2.1.dist-info/METADATA +91 -0
- digitalafarin_cms-0.2.1.dist-info/RECORD +74 -0
- digitalafarin_cms-0.2.1.dist-info/WHEEL +5 -0
- digitalafarin_cms-0.2.1.dist-info/licenses/LICENSE +202 -0
- 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,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,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,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
|
|
File without changes
|
|
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))]
|