Publications and categories

This commit is contained in:
Stepan 2025-12-27 23:51:53 +01:00
parent df896cee39
commit ed84b97f58
24 changed files with 556 additions and 16 deletions

3
.gitignore vendored
View File

@ -2,4 +2,5 @@ db.sqlite3
.env .env
__pycache__/ __pycache__/
/static/ /static/
/media/ /media/
.venv/

View File

@ -49,6 +49,7 @@ INSTALLED_APPS = [
'django_filters', 'django_filters',
'drf_spectacular', 'drf_spectacular',
'publications.apps.PublicationsConfig',
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View File

@ -24,6 +24,7 @@ urlpatterns = [
path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), path('swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
path('api/', include([ path('api/', include([
path('', include('users.urls')) path('', include('users.urls')),
path('', include('publications.urls'))
])), ])),
] ]

0
publications/__init__.py Normal file
View File

19
publications/admin.py Normal file
View File

@ -0,0 +1,19 @@
from django.contrib import admin
from .models import Publication, Rating
# Register your models here.
class PublicationAdmin(admin.ModelAdmin):
list_display = ('pk', 'content_type', 'is_pinned', 'user', 'time_created', 'time_updated')
list_display_links = ('pk', 'content_type')
list_filter = ('content_type', 'is_pinned')
readonly_fields = ('time_created', 'time_updated')
ordering = ('-is_pinned', '-time_created')
search_fields = ('user__username', )
class RatingAdmin(admin.ModelAdmin):
list_display = ('publication', 'score', 'user', 'time_created')
readonly_fields = ('time_created', 'time_updated')
search_fields = ('user__username', )
admin.site.register(Publication, PublicationAdmin)
admin.site.register(Rating, RatingAdmin)

6
publications/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class PublicationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'publications'

26
publications/managers.py Normal file
View File

@ -0,0 +1,26 @@
from typing import TypeVar
from django.db.models import Avg
from django.db import models
T = TypeVar('T')
class PublicationQuerySet(models.QuerySet[T]):
def with_score(self):
qs = self
qs = qs.annotate(average_score=Avg("ratings__score"))
return qs
def only_publish(self):
from .models import Publication
qs = self
return qs.filter(status=Publication.StatusVarioations.PUBLIC.value)
class PublicationManager(models.Manager[T]):
def get_queryset(self):
return PublicationQuerySet[T](self.model, using=self._db).with_score()
def with_related(self):
return self.get_queryset().select_related('user', 'category')
def only_publish(self):
return self.get_queryset().only_publish()

View File

@ -0,0 +1,43 @@
# Generated by Django 5.2.9 on 2025-12-27 20:07
import django.core.validators
import django.db.models.deletion
import publications.models
import publications.utils
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Publication',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(blank=True, null=True, upload_to=publications.models.user_directory_path, validators=[django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp', 'gif']), publications.utils.validate_image_size])),
('video', models.FileField(blank=True, null=True, upload_to=publications.models.user_directory_path, validators=[django.core.validators.FileExtensionValidator(['mp4', 'webm', 'mov']), publications.utils.validate_video_size])),
('content_type', models.CharField(choices=[('image', 'Image'), ('video', 'Video')], max_length=20)),
('status', models.CharField(choices=[('public', 'Public'), ('pending', 'Pending')], default='pending', max_length=50)),
('description', models.CharField(max_length=255)),
('is_pinned', models.BooleanField(default=False)),
('time_created', models.DateTimeField(auto_now_add=True)),
('time_updated', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='Rating',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('score', models.IntegerField(validators=[publications.utils.validate_score])),
('publication', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to='publications.publication')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.2.9 on 2025-12-27 20:09
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('publications', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddConstraint(
model_name='rating',
constraint=models.UniqueConstraint(fields=('user', 'publication'), name='unique_user_publication'),
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 5.2.9 on 2025-12-27 20:18
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('publications', '0002_rating_unique_user_publication'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='publication',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='rating',
name='time_created',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='rating',
name='time_updated',
field=models.DateTimeField(auto_now=True),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 5.2.9 on 2025-12-27 20:18
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('publications', '0003_publication_user_rating_time_created_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='publication',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 5.2.9 on 2025-12-27 22:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('publications', '0004_alter_publication_user'),
]
operations = [
migrations.CreateModel(
name='Category',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
],
),
migrations.AddField(
model_name='publication',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='publications.category'),
),
]

View File

96
publications/models.py Normal file
View File

@ -0,0 +1,96 @@
import os
import uuid
from django.db import models
from django.core.validators import FileExtensionValidator
from django.core.exceptions import ValidationError
from users.models import User
from .utils import validate_image_size, validate_video_size, validate_score
from .managers import PublicationManager
ALLOWED_IMAGE_EXTS = ['jpg', 'jpeg', 'png', 'webp', 'gif']
ALLOWED_VIDEO_EXTS = ['mp4', 'webm', 'mov']
def user_directory_path(instance, filename):
ext = os.path.splitext(filename)[1]
return f'publications/user_{instance.user.id}/{uuid.uuid4()}{ext}'
class Category(models.Model):
name = models.CharField(max_length=50)
def __str__(self):
return self.name
# Create your models here.
class Publication(models.Model):
class StatusVarioations(models.TextChoices):
PUBLIC = 'public', 'Public'
PENDING = 'pending', 'Pending'
class ContentVariations(models.TextChoices):
IMAGE = 'image', 'Image'
VIDEO = 'video', 'Video'
image = models.ImageField(upload_to=user_directory_path, blank=True, null=True, validators=[FileExtensionValidator(ALLOWED_IMAGE_EXTS), validate_image_size])
video = models.FileField(upload_to=user_directory_path, blank=True, null=True, validators=[FileExtensionValidator(ALLOWED_VIDEO_EXTS), validate_video_size])
content_type = models.CharField(max_length=20, choices=ContentVariations.choices)
status = models.CharField(max_length=50, choices=StatusVarioations.choices, default=StatusVarioations.PENDING)
description = models.CharField(max_length=255)
is_pinned = models.BooleanField(default=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, null=False, blank=False)
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True)
time_created = models.DateTimeField(auto_now_add=True)
time_updated = models.DateTimeField(auto_now=True)
objects: PublicationManager["Publication"] = PublicationManager()
def __str__(self):
return f'Content #{self.pk}'
@property
def average_score(self) -> int:
return getattr(self, "average_score", None)
def clean(self):
errors = []
if self.content_type == 'image' and not self.image:
errors.append('Required image file')
if self.content_type == 'video' and not self.video:
errors.append('Required video file')
if self.image and self.video:
errors.append('You must upload either a video or an image, not both')
if errors:
raise ValidationError(errors)
class Rating(models.Model):
score = models.IntegerField(validators=[validate_score], null=False)
user = models.ForeignKey(User, on_delete=models.CASCADE)
publication = models.ForeignKey(Publication, on_delete=models.CASCADE, related_name="ratings")
time_created = models.DateTimeField(auto_now_add=True)
time_updated = models.DateTimeField(auto_now=True)
@staticmethod
def rate_publication(user: User, publication: Publication, score: int):
exists_rate = Rating.objects.filter(user=user, publication=publication).first()
if exists_rate:
exists_rate.score = score
exists_rate.save()
else:
Rating.objects.create(
user=user,
publication=publication,
score=score
)
class Meta:
constraints = [
models.UniqueConstraint(fields=['user', 'publication'], name='unique_user_publication')
]
def __str__(self):
return f'Score {self.score} by {self.user} for {self.publication}'

View File

@ -0,0 +1,36 @@
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from users.serializers import PublicUserSerializer
from .models import Publication, Rating, Category
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = ('pk', 'name')
class PublicationSerializer(serializers.ModelSerializer):
category_detail = CategorySerializer(source="category", read_only=True)
user_detail = PublicUserSerializer(source="user", read_only=True)
average_score = serializers.IntegerField(read_only=True)
class Meta:
model = Publication
fields = ('pk', 'image', 'video', 'content_type', 'status', 'average_score', 'description', 'is_pinned', 'user', 'user_detail', 'category', 'category_detail','time_created', 'time_updated')
read_only_fields = ('time_created', 'time_updated', 'is_pinned', 'user', 'status')
class AdminPublicationSerializer(PublicationSerializer):
class Meta(PublicationSerializer.Meta):
read_only_fields = ('time_created', 'time_updated', 'user')
class RatePublicationSerializer(serializers.Serializer):
score = serializers.IntegerField()
publication = serializers.PrimaryKeyRelatedField(queryset=Publication.objects.only_publish())
def validate_score(self, value):
if value > 5 or value < 1:
raise ValidationError('Score must be between 1 and 5')
return value

3
publications/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

20
publications/urls.py Normal file
View File

@ -0,0 +1,20 @@
from django.contrib import admin
from django.urls import path, include
from .views import *
urlpatterns = [
path('publications/', include([
path('', PublicationsAPIView.as_view()),
path('<int:pk>/', PublicationDetailAPIView.as_view()),
path('rate/', RatePublicationAPIView.as_view())
])),
path('admin/publications/', include([
path('', AdminPublicationsAPIView.as_view()),
path('<int:pk>/', AdminPublicationDetailAPIView.as_view())
])),
path('categories/', include([
path('', CategoryListAPIView.as_view()),
path('<int:pk>/', CategoryDetailAPIView.as_view())
]))
]

26
publications/utils.py Normal file
View File

@ -0,0 +1,26 @@
from rest_framework.permissions import BasePermission
from django.core.exceptions import ValidationError
from users.models import User
MAX_IMAGE_SIZE_MB = 2
MAX_VIDEO_SIZE_MB = 50
def validate_score(score):
if score < 1 or score > 5:
raise ValidationError('Score must be between 1 and 5')
def validate_image_size(file):
max_size = MAX_IMAGE_SIZE_MB * 1024 * 1024
if file.size > max_size:
raise ValidationError(f'Max size file is {MAX_IMAGE_SIZE_MB} MB')
def validate_video_size(file):
max_size = MAX_VIDEO_SIZE_MB * 1024 * 1024
if file.size > max_size:
raise ValidationError(f'Max size file is {MAX_VIDEO_SIZE_MB} MB')
class IsProfessorOnly(BasePermission):
def has_permission(self, request, view):
user: User = request.user
return user.is_authenticated and (user.role == User.Roles.PROFESSOR.value or user.is_superuser)

132
publications/views.py Normal file
View File

@ -0,0 +1,132 @@
from rest_framework.permissions import IsAuthenticated, IsAdminUser
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView, RetrieveAPIView
from drf_spectacular.utils import extend_schema
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters
from rest_framework import status
from .utils import IsProfessorOnly
from .models import Publication, Rating, Category
from .serializers import PublicationSerializer, AdminPublicationSerializer, RatePublicationSerializer, CategorySerializer
from users.models import User
from project.serializers import MessageResponseSerializer
# Create your views here.
@extend_schema(
tags=['Publications'],
methods=['GET'],
summary='List publications',
)
@extend_schema(
tags=['Publications'],
methods=['POST'],
summary='Create new publication (only authorized)',
)
class PublicationsAPIView(ListCreateAPIView):
queryset = Publication.objects.with_related().only_publish()
serializer_class = PublicationSerializer
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['is_pinned', 'user', 'content_type', 'category']
ordering_fields = ['is_pinned', 'average_score', 'time_created', 'time_updated']
ordering = ['-time_created']
search_fields = ['user__username', 'description']
def get_permissions(self):
if self.request.method == 'POST':
return [IsAuthenticated()]
return super().get_permissions()
@extend_schema(
tags=['Publications'],
summary='Publication details',
)
class PublicationDetailAPIView(RetrieveAPIView):
queryset = Publication.objects.with_related().only_publish()
serializer_class = PublicationSerializer
@extend_schema(
tags=['Publications'],
methods=['GET'],
summary='List publications (only admin or professor)',
)
@extend_schema(
tags=['Publications'],
methods=['POST'],
summary='Create new publication (only admin or professor)',
)
class AdminPublicationsAPIView(PublicationsAPIView):
queryset = Publication.objects.with_related()
serializer_class = AdminPublicationSerializer
filterset_fields = ['is_pinned', 'user', 'content_type', 'category', 'status']
ordering_fields = ['is_pinned', 'average_score', 'time_created', 'time_updated', 'status']
def get_permissions(self):
return [IsProfessorOnly()]
@extend_schema(
tags=['Publications'],
summary='CRUD for publication (only admin or professor)',
)
class AdminPublicationDetailAPIView(RetrieveUpdateDestroyAPIView):
queryset = Publication.objects.with_related()
serializer_class = AdminPublicationSerializer
permission_classes = [IsProfessorOnly]
@extend_schema(
tags=['Categories'],
methods=['GET'],
summary='List categories',
)
@extend_schema(
tags=['Categories'],
methods=['POST'],
summary='Create new category (only admin)',
)
class CategoryListAPIView(ListCreateAPIView):
queryset = Category.objects.all()
serializer_class = CategorySerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
ordering = ['name']
search_fields = ['name']
def get_permissions(self):
if self.request.method == 'POST':
return [IsAdminUser]
return super().get_permissions()
@extend_schema(
tags=['Categories'],
summary='CRUD for category (only admin)',
)
class CategoryDetailAPIView(RetrieveUpdateDestroyAPIView):
queryset = Category.objects.all()
serializer_class = CategorySerializer
permission_classes = [IsAdminUser]
class RatePublicationAPIView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=['Publications'],
summary='Rate publication',
request=RatePublicationSerializer,
responses=MessageResponseSerializer
)
def post(self, request: Request, format=None):
serializer = RatePublicationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user: User = request.user
publication = serializer.validated_data['publication']
score = serializer.validated_data['score']
Rating.rate_publication(user, publication, score)
return Response(
{"detail": "Rating saved successfully"},
status=status.HTTP_200_OK
)

View File

@ -0,0 +1,24 @@
# Generated by Django 5.2.9 on 2025-12-27 20:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0003_schoolid_remove_user_school_id_user_school_index'),
]
operations = [
migrations.AlterField(
model_name='schoolid',
name='school_index',
field=models.CharField(db_index=True, max_length=8, unique=True),
),
migrations.AlterField(
model_name='user',
name='school_index',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='users.schoolid'),
),
]

View File

@ -16,7 +16,7 @@ class User(AbstractUser):
PROFESSOR = 'prof', 'Professor' PROFESSOR = 'prof', 'Professor'
school_index = models.ForeignKey(SchoolID, on_delete=models.PROTECT) school_index = models.ForeignKey(SchoolID, on_delete=models.PROTECT)
role = models.CharField(max_length=20, choices=Roles, default=Roles.COMMON) role = models.CharField(max_length=20, choices=Roles.choices, default=Roles.COMMON)
@staticmethod @staticmethod
def authenticate(request, username_or_schoolid, password): def authenticate(request, username_or_schoolid, password):

View File

@ -6,13 +6,20 @@ class SchoolIDSerializer(serializers.ModelSerializer):
model = SchoolID model = SchoolID
fields = ('id', 'school_index') fields = ('id', 'school_index')
class UserSerializer(serializers.ModelSerializer): class PublicUserSerializer(serializers.ModelSerializer):
is_superuser = serializers.BooleanField(read_only=True) school_index_detail = SchoolIDSerializer(source='school_index', read_only=True)
school_index_object = SchoolIDSerializer(source='school_index', read_only=True)
class Meta: class Meta:
model = User model = User
fields = ('id', 'username', 'school_index_object', 'school_index', 'email', 'role', 'is_superuser') fields = ('id', 'username', 'school_index_detail', 'school_index', 'role', 'is_superuser')
class UserSerializer(serializers.ModelSerializer):
is_superuser = serializers.BooleanField(read_only=True)
school_index_detail = SchoolIDSerializer(source='school_index', read_only=True)
class Meta:
model = User
fields = ('id', 'username', 'school_index_detail', 'school_index', 'email', 'role', 'is_superuser')
class UserForAdminSerializer(UserSerializer): class UserForAdminSerializer(UserSerializer):
is_superuser = serializers.BooleanField() is_superuser = serializers.BooleanField()

View File

@ -10,12 +10,12 @@ urlpatterns = [
path('register/', RegisterView.as_view()), path('register/', RegisterView.as_view()),
])), ])),
path('users/', include([ path('admin/users/', include([
path('<int:pk>/', UserAPIView.as_view()), path('<int:pk>/', UserAPIView.as_view()),
path('', UserListAPIView.as_view()) path('', UserListAPIView.as_view())
])), ])),
path('schools/', include([ path('admin/schools/', include([
path('<int:pk>/', SchoolAPIView.as_view()), path('<int:pk>/', SchoolAPIView.as_view()),
path('', SchoolListAPIView.as_view()) path('', SchoolListAPIView.as_view())
])), ])),

View File

@ -19,7 +19,7 @@ from .serializers import UserSerializer, LoginSerializer, RegisterSerializer, To
@extend_schema(tags=['Auth'], @extend_schema(tags=['Auth'],
description="Get current authenticated user") summary="Get current authenticated user")
class AboutMeView(RetrieveAPIView): class AboutMeView(RetrieveAPIView):
serializer_class = UserSerializer serializer_class = UserSerializer
@ -31,7 +31,7 @@ class AboutMeView(RetrieveAPIView):
class LoginView(APIView): class LoginView(APIView):
@extend_schema(tags=['Auth'], @extend_schema(tags=['Auth'],
description='Authenticate using login or password', summary='Authenticate using login or password',
request=LoginSerializer, request=LoginSerializer,
responses={ responses={
200: TokenSerializer, 200: TokenSerializer,
@ -54,7 +54,7 @@ class LoginView(APIView):
class RegisterView(APIView): class RegisterView(APIView):
@extend_schema(tags=['Auth'], @extend_schema(tags=['Auth'],
description='Register new user using school_id', summary='Register new user using school_id',
request=RegisterSerializer, request=RegisterSerializer,
responses={ responses={
201: MessageResponseSerializer, 201: MessageResponseSerializer,
@ -74,28 +74,28 @@ class RegisterView(APIView):
@extend_schema(tags=['Users'], @extend_schema(tags=['Users'],
description='List of all current users') summary='List of all current users (only admin)')
class UserListAPIView(ListAPIView): class UserListAPIView(ListAPIView):
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = UserForAdminSerializer serializer_class = UserForAdminSerializer
permission_classes = [IsAdminUser] permission_classes = [IsAdminUser]
@extend_schema(tags=['Users'], @extend_schema(tags=['Users'],
description='CRUD for specific user') summary='CRUD for specific user (only admin)')
class UserAPIView(RetrieveUpdateDestroyAPIView): class UserAPIView(RetrieveUpdateDestroyAPIView):
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = UserForAdminSerializer serializer_class = UserForAdminSerializer
permission_classes = [IsAdminUser] permission_classes = [IsAdminUser]
@extend_schema(tags=['Users'], @extend_schema(tags=['Users'],
description='List of all available school ids') summary='List of all available school ids (only admin)')
class SchoolListAPIView(ListAPIView): class SchoolListAPIView(ListAPIView):
queryset = SchoolID.objects.all() queryset = SchoolID.objects.all()
serializer_class = SchoolIDSerializer serializer_class = SchoolIDSerializer
permission_classes = [IsAdminUser] permission_classes = [IsAdminUser]
@extend_schema(tags=['Users'], @extend_schema(tags=['Users'],
description='CRUD for specific school id') summary='CRUD for specific school id (only admin)')
class SchoolAPIView(RetrieveUpdateDestroyAPIView): class SchoolAPIView(RetrieveUpdateDestroyAPIView):
queryset = SchoolID.objects.all() queryset = SchoolID.objects.all()
serializer_class = SchoolIDSerializer serializer_class = SchoolIDSerializer