Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions articles/migrations/0008_article_slug.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.25 on 2025-12-08 09:42

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("articles", "0007_add_editors_group"),
]

operations = [
migrations.AddField(
model_name="article",
name="slug",
field=models.SlugField(blank=True, max_length=255, null=True, unique=True),
),
]
26 changes: 25 additions & 1 deletion articles/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from django.conf import settings
from django.db import models
from django.utils.text import slugify

from main.models import TimestampedModel
from profiles.utils import article_image_upload_uri
Expand All @@ -20,15 +21,38 @@ class Article(TimestampedModel):
)
content = models.JSONField(default={})
title = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True, blank=True, null=True)
is_published = models.BooleanField(default=False)

def save(self, *args, **kwargs):
previous = Article.objects.get(pk=self.pk) if self.pk else None
was_published = getattr(previous, "is_published", None)

# Always initialize slug
slug = self.slug or None

if not was_published and self.is_published:
max_length = self._meta.get_field("slug").max_length

base_slug = slugify(self.title)[:max_length]
slug = base_slug
counter = 1

# Prevent collisions
while Article.objects.filter(slug=slug).exclude(pk=self.pk).exists():
suffix = f"-{counter}"
slug = f"{base_slug[: max_length - len(suffix)]}{suffix}"
counter += 1

self.slug = slug
super().save(*args, **kwargs)


class ArticleImageUpload(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
image_file = models.ImageField(
null=True, upload_to=article_image_upload_uri, max_length=2083, editable=False
)

created_at = models.DateTimeField(auto_now_add=True)

def __str__(self):
Expand Down
2 changes: 2 additions & 0 deletions articles/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class RichTextArticleSerializer(serializers.ModelSerializer):
created_on = serializers.DateTimeField(read_only=True, required=False)
updated_on = serializers.DateTimeField(read_only=True, required=False)
content = serializers.JSONField(default={})
slug = serializers.SlugField(max_length=60, required=False, allow_blank=True)
title = serializers.CharField(max_length=255)
user = UserSerializer(read_only=True)

Expand All @@ -45,6 +46,7 @@ class Meta:
"created_on",
"updated_on",
"is_published",
"slug",
]


Expand Down
5 changes: 3 additions & 2 deletions articles/urls.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"""URL configuration for staff_content"""

from django.urls import include, path, re_path
from rest_framework.routers import SimpleRouter

Expand All @@ -15,13 +13,16 @@
)

app_name = "articles"

urlpatterns = [
re_path(
r"^api/v1/",
include(
(
[
# All ViewSet routes
*v1_router.urls,
# Media upload endpoint
path(
"upload-media/",
MediaUploadView.as_view(),
Expand Down
34 changes: 34 additions & 0 deletions articles/views.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
from django.conf import settings
from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator
from drf_spectacular.utils import (
OpenApiParameter,
OpenApiResponse,
extend_schema,
extend_schema_view,
)
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
Expand Down Expand Up @@ -77,6 +80,37 @@ def destroy(self, request, *args, **kwargs):
clear_views_cache()
return super().destroy(request, *args, **kwargs)

@extend_schema(
summary="Retrieve article by ID or slug",
description="If the path parameter is numeric → ID, else → slug.",
parameters=[
OpenApiParameter(
name="identifier",
type=str,
location=OpenApiParameter.PATH,
description="Article ID (number) or slug (string)",
required=True,
)
],
responses={200: RichTextArticleSerializer, 404: OpenApiResponse()},
)
@action(
detail=False,
methods=["get"],
url_path="detail/(?P<identifier>[^/.]+)",
url_name="detail-by-id-or-slug",
)
def detail_by_id_or_slug(self, _request, identifier):
qs = self.get_queryset()

if identifier.isdigit():
article = get_object_or_404(qs, id=int(identifier))
else:
article = get_object_or_404(qs, slug=identifier)

serializer = self.get_serializer(article)
return Response(serializer.data, status=status.HTTP_200_OK)


@extend_schema_view(
post=extend_schema(
Expand Down
73 changes: 66 additions & 7 deletions articles/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import pytest
from rest_framework.reverse import reverse

from articles.models import Article
from main.factories import UserFactory

pytestmark = [pytest.mark.django_db]
Expand All @@ -22,11 +23,69 @@ def test_article_creation(staff_client, user):
assert json["title"] == "Some title"


@pytest.mark.parametrize("is_staff", [True, False])
def test_article_permissions(client, is_staff):
user = UserFactory.create(is_staff=True)
client.force_login(user)
url = reverse("articles:v1:articles-list")
def test_retrieve_article_by_id(client, user):
"""Should retrieve published article by numeric ID"""
article = Article.objects.create(
title="Test Article",
content={},
is_published=True,
user=user,
)

url = reverse(
"articles:v1:articles-detail-by-id-or-slug",
kwargs={"identifier": str(article.id)},
)

resp = client.get(url)
data = resp.json()

assert resp.status_code == 200
assert data["id"] == article.id
assert data["title"] == "Test Article"


def test_retrieve_article_by_slug(client, user):
"""Should retrieve published article by slug"""
article = Article.objects.create(
title="Slug Article",
content={},
is_published=True,
user=user,
)

url = reverse(
"articles:v1:articles-detail-by-id-or-slug",
kwargs={"identifier": article.slug},
)

resp = client.get(url)
resp.json()
assert resp.status_code == 200 if is_staff else 403
data = resp.json()

assert resp.status_code == 200
assert data["slug"] == article.slug
assert data["title"] == "Slug Article"


def test_staff_can_access_unpublished_article(client):
"""Staff should be able to see unpublished articles"""
staff_user = UserFactory.create(is_staff=True)
client.force_login(staff_user)

article = Article.objects.create(
title="Draft Article",
content={},
is_published=False,
user=staff_user,
)

url = reverse(
"articles:v1:articles-detail-by-id-or-slug",
kwargs={"identifier": str(article.id)},
)

resp = client.get(url)
data = resp.json()

assert resp.status_code == 200
assert data["id"] == article.id
Loading
Loading