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
46 changes: 46 additions & 0 deletions apps/political_figure/migrations/0002_achievement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Generated by Django 5.1.5 on 2025-10-28 10:58

import apps.political_figure.models.achievements
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('political_figure', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='Achievement',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('deleted', models.DateTimeField(db_index=True, editable=False, null=True)),
('deleted_by_cascade', models.BooleanField(default=False, editable=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
('title', models.CharField(max_length=255)),
('category', models.CharField(choices=[('leadership', 'Leadership'), ('academic', 'Academic'), ('public_service', 'Public Service'), ('military', 'Military'), ('other', 'Other')], default='other', max_length=50)),
('description', models.TextField(blank=True)),
('year', models.IntegerField(validators=[apps.political_figure.models.achievements.validate_4_digit_year])),
('awarding_body', models.CharField(max_length=255)),
('evidence_link', models.URLField(blank=True, max_length=500, null=True)),
('status', models.CharField(choices=[('unverified', 'Unverified'), ('pending', 'Pending'), ('verified', 'Verified')], default='unverified', max_length=50)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL)),
('political_figure', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='achievements', to='political_figure.politicalfigure')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Achievement',
'verbose_name_plural': 'Achievements',
'db_table': 'political_figure_achievement',
'ordering': ['-year', 'title'],
'unique_together': {('political_figure', 'title', 'year')},
},
),
]
2 changes: 2 additions & 0 deletions apps/political_figure/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .core import PoliticalFigure
from .achievements import Achievement
64 changes: 64 additions & 0 deletions apps/political_figure/models/achievements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from django.db import models
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from apps import political_figure
from utils.core.base_models import BaseModel
from .core import PoliticalFigure


# Custom validator for 4-digit year
def validate_4_digit_year(value):
"""Validator to ensure year is exactly 4 digits."""
if not (1000 <= value <= 9999):
raise ValidationError(
_('%(value)s is not a valid 4-digit year.'),
params={'value': value},
)


class Achievement(BaseModel):
class CategoryChoices(models.TextChoices):
LEADERSHIP = 'leadership', _('Leadership')
ACADEMIC = 'academic', _('Academic')
PUBLIC_SERVICE = 'public_service', _('Public Service')
MILITARY = 'military', _('Military')
OTHER = 'other', _('Other')

class StatusChoices(models.TextChoices):
UNVERIFIED = 'unverified', _('Unverified')
PENDING = 'pending', _('Pending')
VERIFIED = 'verified', _('Verified')


political_figure = models.ForeignKey(
PoliticalFigure,
on_delete=models.CASCADE,
related_name='achievements'
)

title = models.CharField(max_length=255)
category = models.CharField(
max_length=50,
choices=CategoryChoices.choices,
default=CategoryChoices.OTHER,
)
description = models.TextField(blank=True)
year = models.IntegerField(validators=[validate_4_digit_year])
awarding_body = models.CharField(max_length=255)
evidence_link = models.URLField(max_length=500, blank=True, null=True)
status = models.CharField(
max_length=50,
choices=StatusChoices.choices,
default=StatusChoices.UNVERIFIED,
)

def __str__(self):
return f"{self.title} ({self.year}) - {self.political_figure.full_name}"


class Meta:
ordering = ['-year', 'title']
db_table = "political_figure_achievement"
verbose_name = "Achievement"
verbose_name_plural = "Achievements"
unique_together = ('political_figure', 'title', 'year')
File renamed without changes.
45 changes: 43 additions & 2 deletions apps/political_figure/serializers.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from rest_framework import serializers
from apps.core.models import Address
from apps.core.serializers import CreateAddressSerializer, UpdateAddressSerializer
from apps.political_figure.models import PoliticalFigure

from .models.core import PoliticalFigure
from .models.achievements import Achievement

class CreatePoliticalFigureSerializer(serializers.ModelSerializer):

Expand Down Expand Up @@ -77,3 +77,44 @@ class Meta:
"instagram_url",
"is_active",
]



class AchievementSerializer(serializers.ModelSerializer):
"""
Serializer for the Achievement model. Handles input validation and output formatting.
"""
# Read-only field for displaying the figure's full name
political_figure_full_name = serializers.CharField(
source='political_figure.full_name',
read_only=True
)

class Meta:
model = Achievement
fields = [
'id',
'uuid',
'political_figure',
'political_figure_full_name',
'title',
'category',
'description',
'year',
'awarding_body',
'evidence_link',
'status',
'created_at',
'updated_at',
]
read_only_fields = ['id', 'uuid', 'political_figure_full_name', 'created_at', 'updated_at']

def validate_year(self, value):
"""
Serializer-level validation to prevent future dates.
The model validator handles the 4-digit format.
"""
import datetime
if value > datetime.date.today().year:
raise serializers.ValidationError("Achievement year cannot be in the future.")
return value
9 changes: 9 additions & 0 deletions apps/political_figure/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from . import views

router = DefaultRouter()
router.register(
r'achievements',
views.AchievementViewSet,
basename='achievement'
)

urlpatterns = [
# Political Party
path(
Expand Down Expand Up @@ -28,4 +36,5 @@
views.DeletePoliticalFigureAPI.as_view(),
name="delete-political-party",
),
path("", include(router.urls)),
]
8 changes: 8 additions & 0 deletions apps/political_figure/views/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from .core import (
GetPoliticalFigureDetailAPI,
GetPoliticalFigureListAPI,
CreatePoliticalFigureAPI,
UpdatePoliticalFigureAPI,
DeletePoliticalFigureAPI,
)
from .achievements import AchievementViewSet
37 changes: 37 additions & 0 deletions apps/political_figure/views/achievements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from rest_framework import viewsets, permissions
from rest_framework.authentication import SessionAuthentication
from rest_framework_simplejwt.authentication import JWTAuthentication
from drf_spectacular.utils import extend_schema

from utils.core.permissions import IsAdminOrSuper
from ..models.achievements import Achievement
from ..serializers import AchievementSerializer

class AchievementViewSet(viewsets.ModelViewSet):
"""
CRUD endpoints for Political Figure Achievements.
"""
queryset = Achievement.objects.select_related('political_figure').all()
serializer_class = AchievementSerializer

authentication_classes = [JWTAuthentication, SessionAuthentication]

def get_permissions(self):
"""
Instantiates and returns the list of permissions that the view requires.
GET requests (list/retrieve) are read-only for anyone.
POST, PATCH, DELETE require Admin or Super role.
"""
if self.action in ['list', 'retrieve']:
permission_classes = [permissions.AllowAny]
else:
permission_classes = [IsAdminOrSuper]
return [permission() for permission in permission_classes]

@extend_schema(summary="List all Achievements or filter by figure_id")
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)

@extend_schema(summary="Create a new Achievement (Admin/Super Only)")
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django.shortcuts import get_object_or_404
from rest_framework import serializers
from apps.core.serializers import GetAddressSerializer
from apps.political_figure.models import PoliticalFigure
from ..models.core import PoliticalFigure
from utils.core.base_views import PublicAPIView
from utils.core.response_wrappers import NoContentResponse, OKResponse
from utils.political_figure.core import PoliticalFigureUtil
Expand Down
18 changes: 18 additions & 0 deletions utils/core/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,21 @@ def has_permission(self, request, view):

def has_object_permission(self, request, view, obj):
return request.user.role == User.Roles.MEMBER


class IsAdminOrSuper(permissions.BasePermission):
"""
Allows access only to Super or Admin roles.
"""

message = "You are not authorized to perform this action (Requires Admin or Super role)."

def has_permission(self, request, view):
user = request.user
if not user or not user.is_authenticated:
return False

return user.role in [User.Roles.SUPER, User.Roles.ADMIN]

def has_object_permission(self, request, view, obj):
return self.has_permission(request, view)