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
27 changes: 27 additions & 0 deletions apps/publications/migrations/0003_partner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 6.0.7 on 2026-07-30 19:54

import django.core.validators
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('publications', '0002_alter_carouselitem_url'),
]

operations = [
migrations.CreateModel(
name='Partner',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ordering', models.PositiveIntegerField(db_index=True, default=0, verbose_name='ordering')),
('logo', models.ImageField(help_text='The logo for this partner', upload_to='partners')),
('name', models.CharField(help_text="The partner's name", max_length=150)),
('url', models.CharField(blank=True, help_text='Full URL or relative path (e.g. /about).', max_length=200, validators=[django.core.validators.RegexValidator('^(https?://.+|/.*)$', 'Enter a full URL or a relative path starting with /.')])),
],
options={
'ordering': ['ordering'],
},
),
]
18 changes: 18 additions & 0 deletions apps/publications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@ class Meta:
ordering = ["ordering"]


class Partner(models.Model):
ordering = models.PositiveIntegerField(default=0, db_index=True, verbose_name="ordering")
logo = models.ImageField(upload_to="partners", help_text="The logo for this partner")
name = models.CharField(max_length=150, help_text="The partner's name")
url = models.CharField(
max_length=200,
blank=True,
validators=[RegexValidator(r"^(https?://.+|/.*)$", "Enter a full URL or a relative path starting with /.")],
help_text="Full URL or relative path (e.g. /about).",
)

def __str__(self):
return self.name

class Meta:
ordering = ["ordering"]


class Event(models.Model):
title = models.CharField(max_length=150)
slug = models.SlugField(max_length=150, unique=True)
Expand Down
58 changes: 57 additions & 1 deletion apps/publications/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,21 @@ paths:
type: array
items:
$ref: '#/components/schemas/carouselItem'


tags:
- Media & publications
/api/v1/media/partners/:
get:
operationId: partners-list
responses:
200:
description: List of partners
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/partnerItem'
tags:
- Media & publications
/api/v1/media/events/:
Expand Down Expand Up @@ -139,6 +153,22 @@ paths:
$ref: '#/components/schemas/carouselManagementItem'
tags:
- media-management
/api/v1/media/management/partners/:
get:
operationId: management-partners-list
security:
- api_key: []
responses:
200:
description: List partners for management.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/partnerManagementItem'
tags:
- media-management
/api/v1/media/publications/:
get:
operationId: publications-list
Expand Down Expand Up @@ -379,6 +409,32 @@ components:
type: string
ordering:
type: integer
partnerItem:
type: object
properties:
id:
type: integer
name:
type: string
url:
type: string
logo:
type: string
ordering:
type: integer
partnerManagementItem:
type: object
properties:
id:
type: integer
name:
type: string
url:
type: string
logo:
type: string
ordering:
type: integer
tags:
- name: events
- name: Media & publications
Expand Down
30 changes: 29 additions & 1 deletion apps/publications/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from rest_framework import serializers

from apps.publications.models import CarouselItem, Comment, Event, Publication
from apps.publications.models import CarouselItem, Comment, Event, Partner, Publication
from apps.users.serializers import UserSummarySerializer


Expand All @@ -23,6 +23,12 @@ class Meta:
fields = ["title", "url", "image"]


class PartnerSerializer(serializers.ModelSerializer):
class Meta:
model = Partner
fields = ["id", "name", "url", "logo", "ordering"]


class EventListSerializer(serializers.ModelSerializer):
class Meta:
model = Event
Expand Down Expand Up @@ -163,3 +169,25 @@ def to_internal_value(self, data):
class Meta:
model = CarouselItem
fields = ["id", "title", "url", "image", "ordering"]


class PartnerManagementSerializer(serializers.ModelSerializer):
class LogoPathOrUploadField(serializers.ImageField):
"""
Accept either an uploaded image file or a string path/URL.
String values let admins manually edit existing DB logo paths.
"""

def to_internal_value(self, data):
if isinstance(data, str):
value = data.strip()
if not value:
raise serializers.ValidationError("Logo path cannot be empty.")
return value
return super().to_internal_value(data)

logo = LogoPathOrUploadField()

class Meta:
model = Partner
fields = ["id", "name", "url", "logo", "ordering"]
11 changes: 10 additions & 1 deletion apps/publications/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import factory
from factory.django import DjangoModelFactory

from apps.publications.models import CarouselItem, Comment, Event, Publication
from apps.publications.models import CarouselItem, Comment, Event, Partner, Publication


class CarouselItemFactory(DjangoModelFactory):
Expand All @@ -14,6 +14,15 @@ class Meta:
url = factory.Faker("url")


class PartnerFactory(DjangoModelFactory):
class Meta:
model = Partner

logo = factory.django.ImageField()
name = factory.Faker("company")
url = factory.Faker("url")


class EventFactory(DjangoModelFactory):
class Meta:
model = Event
Expand Down
62 changes: 61 additions & 1 deletion apps/publications/tests/tests.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
from io import BytesIO

from django.core.files.uploadedfile import SimpleUploadedFile
from PIL import Image
from rest_framework import status
from rest_framework.test import APIClient, APITestCase

from apps.publications.tests.factories import CarouselItemFactory, EventFactory, PublicationFactory
from apps.publications.models import Partner
from apps.publications.tests.factories import CarouselItemFactory, EventFactory, PartnerFactory, PublicationFactory
from apps.users.tests.factories import UserFactory


def _tiny_image(name="logo.png"):
buf = BytesIO()
Image.new("RGB", (1, 1)).save(buf, format="PNG")
buf.seek(0)
return SimpleUploadedFile(name, buf.read(), content_type="image/png")


class CarouselItemAPITestCase(APITestCase):
Expand All @@ -15,6 +28,53 @@ def test_carousel_items_api(self):
assert len(response.data) == 3, response.data


class PartnerAPITestCase(APITestCase):
def setUp(self):
self.client = APIClient()
self.partners = PartnerFactory.create_batch(3)

def test_partners_api(self):
response = self.client.get("/api/v1/media/partners/")
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 3, response.data
assert {"id", "name", "url", "logo", "ordering"} <= set(response.data[0].keys())


class PartnerManagementAPITestCase(APITestCase):
def setUp(self):
self.client = APIClient()
self.superuser = UserFactory(is_superuser=True, is_staff=True)
self.client.force_authenticate(self.superuser)

def test_create_partner(self):
payload = {"name": "Test Partner", "url": "https://example.org", "logo": _tiny_image()}
response = self.client.post("/api/v1/media/management/partners/", payload, format="multipart")
assert response.status_code == status.HTTP_201_CREATED, response.data
assert Partner.objects.filter(name="Test Partner").exists()

def test_update_partner_ordering(self):
partner = PartnerFactory()
response = self.client.patch(f"/api/v1/media/management/partners/{partner.id}/", {"ordering": 5}, format="json")
assert response.status_code == status.HTTP_200_OK, response.data
partner.refresh_from_db()
assert partner.ordering == 5

def test_delete_partner(self):
partner = PartnerFactory()
response = self.client.delete(f"/api/v1/media/management/partners/{partner.id}/")
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Partner.objects.filter(id=partner.id).exists()

def test_anonymous_cannot_write(self):
self.client.force_authenticate(None)
response = self.client.post(
"/api/v1/media/management/partners/",
{"name": "x", "url": "", "logo": _tiny_image()},
format="multipart",
)
assert response.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)


class EventsAPITestCase(APITestCase):
def setUp(self):
self.client = APIClient()
Expand Down
4 changes: 4 additions & 0 deletions apps/publications/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
CommentManagementViewSet,
EventManagementViewSet,
EventViewSet,
PartnerManagementViewSet,
PartnerViewSet,
PublicationManagementViewSet,
PublicationViewSet,
)
Expand All @@ -15,8 +17,10 @@
router.register("events", EventViewSet, basename="events")
router.register("publications", PublicationViewSet, basename="publications")
router.register("carousel-items", CarouselItemViewSet, basename="carousel-items")
router.register("partners", PartnerViewSet, basename="partners")
router.register("management/publications", PublicationManagementViewSet, basename="management-publications")
router.register("management/events", EventManagementViewSet, basename="management-events")
router.register("management/comments", CommentManagementViewSet, basename="management-comments")
router.register("management/carousel-items", CarouselItemManagementViewSet, basename="management-carousel-items")
router.register("management/partners", PartnerManagementViewSet, basename="management-partners")
urlpatterns = router.urls
16 changes: 15 additions & 1 deletion apps/publications/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@
)
from apps.publications.models import Comment

from .models import CarouselItem, Event, Publication
from .models import CarouselItem, Event, Partner, Publication
from .serializers import (
CarouselItemManagementSerializer,
CarouselItemSerializer,
CommentManagementSerializer,
EventDetailSerializer,
EventListSerializer,
EventManagementSerializer,
PartnerManagementSerializer,
PartnerSerializer,
PublicationDetailSerializer,
PublicationListManagementSerializer,
PublicationListSerializer,
Expand Down Expand Up @@ -62,6 +64,12 @@ class CarouselItemViewSet(GenericViewSet, ListModelMixin):
pagination_class = None


class PartnerViewSet(GenericViewSet, ListModelMixin):
queryset = Partner.objects.all()
serializer_class = PartnerSerializer
pagination_class = None


class PublicationManagementViewSet(ActionSerializerMixin, FilterablePrivilegedViewSet):
queryset = get_publication_management_queryset()
filterset_fields = ["status", "is_blog_post", "is_news", "is_featured"]
Expand Down Expand Up @@ -104,3 +112,9 @@ class CarouselItemManagementViewSet(UnpaginatedPrivilegedViewSet):
queryset = CarouselItem.objects.all()
serializer_class = CarouselItemManagementSerializer
parser_classes = [MultiPartParser, JSONParser]


class PartnerManagementViewSet(UnpaginatedPrivilegedViewSet):
queryset = Partner.objects.all()
serializer_class = PartnerManagementSerializer
parser_classes = [MultiPartParser, JSONParser]
Loading