This commit is contained in:
Jakub Kaniecki
2025-05-18 16:23:03 +02:00
commit 12c76e3e5a
220 changed files with 31696 additions and 0 deletions

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

3
backend/content/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
backend/content/apps.py Normal file
View File

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

View File

@@ -0,0 +1,59 @@
# Generated by Django 4.2 on 2025-05-01 18:16
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
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=255)),
('slug', models.SlugField(unique=True)),
],
),
migrations.CreateModel(
name='Comment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(max_length=2000)),
('approved', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Page',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('content', models.TextField()),
('status', models.CharField(choices=[('draft', 'Draft'), ('pending', 'Pending Review'), ('published', 'Published')], default='draft', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Post',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('content', models.TextField()),
('status', models.CharField(choices=[('draft', 'Draft'), ('pending', 'Pending Review'), ('published', 'Published')], default='draft', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'abstract': False,
},
),
]

View File

@@ -0,0 +1,53 @@
# Generated by Django 4.2 on 2025-05-01 18:16
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('content', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='post',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='post',
name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='content.category'),
),
migrations.AddField(
model_name='page',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='page',
name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='content.category'),
),
migrations.AddField(
model_name='comment',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='comment',
name='page',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='content.page'),
),
migrations.AddField(
model_name='comment',
name='post',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='content.post'),
),
]

View File

@@ -0,0 +1,30 @@
from django.db import migrations
def create_initial_categories(apps, schema_editor):
Category = apps.get_model('content', 'Category')
default_categories = [
{'name': 'General', 'slug': 'general'},
{'name': 'Technology', 'slug': 'technology'},
{'name': 'Science', 'slug': 'science'},
{'name': 'Programming', 'slug': 'programming'},
{'name': 'News', 'slug': 'news'},
]
for category in default_categories:
Category.objects.get_or_create(
name=category['name'],
slug=category['slug']
)
def remove_initial_categories(apps, schema_editor):
Category = apps.get_model('content', 'Category')
Category.objects.filter(slug__in=['general', 'technology', 'science', 'programming', 'news']).delete()
class Migration(migrations.Migration):
dependencies = [
('content', '0001_initial'),
]
operations = [
migrations.RunPython(create_initial_categories, remove_initial_categories),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 4.2 on 2025-05-01 19:05
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('content', '0002_initial'),
]
operations = [
migrations.CreateModel(
name='UploadedImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='uploads/%Y/%m/%d/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'gif', 'webp'])])),
('title', models.CharField(max_length=255)),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('file_size', models.PositiveIntegerField()),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-uploaded_at'],
},
),
]

View File

@@ -0,0 +1,14 @@
# Generated by Django 4.2 on 2025-05-01 19:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('content', '0002_initial_categories'),
('content', '0003_uploadedimage'),
]
operations = [
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 4.2 on 2025-05-04 10:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0004_merge_0002_initial_categories_0003_uploadedimage'),
]
operations = [
migrations.CreateModel(
name='Tag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(unique=True)),
],
),
migrations.AddField(
model_name='page',
name='tags',
field=models.ManyToManyField(blank=True, to='content.tag'),
),
migrations.AddField(
model_name='post',
name='tags',
field=models.ManyToManyField(blank=True, to='content.tag'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 4.2 on 2025-05-10 23:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0005_tag_page_tags_post_tags'),
]
operations = [
migrations.AddField(
model_name='page',
name='slug',
field=models.SlugField(blank=True, unique=True),
),
migrations.AddField(
model_name='post',
name='slug',
field=models.SlugField(blank=True, unique=True),
),
]

View File

88
backend/content/models.py Normal file
View File

@@ -0,0 +1,88 @@
from django.db import models
from users.models import User
from django.conf import settings
from django.utils import timezone
from django.core.validators import FileExtensionValidator
class Category(models.Model):
name = models.CharField(max_length=255)
slug = models.SlugField(unique=True)
def __str__(self):
return self.name
class AbstractContent(models.Model):
STATUS_CHOICES = (
('draft', 'Draft'),
('pending', 'Pending Review'),
('published', 'Published'),
)
title = models.CharField(max_length=255)
slug = models.SlugField(unique=True, blank=True)
content = models.TextField()
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='draft')
author = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
category = models.ForeignKey('Category', on_delete=models.CASCADE)
tags = models.ManyToManyField('Tag', blank=True)
class Meta:
abstract = True
def __str__(self):
return self.title
class Post(AbstractContent):
pass
class Page(AbstractContent):
pass
class Comment(models.Model):
content = models.TextField(max_length=2000)
approved = models.BooleanField(default=False)
author = models.ForeignKey(User, on_delete=models.CASCADE)
post = models.ForeignKey(Post, null=True, blank=True, on_delete=models.CASCADE)
page = models.ForeignKey(Page, null=True, blank=True, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
class UploadedImage(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
image = models.ImageField(
upload_to='uploads/%Y/%m/%d/',
validators=[FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'gif', 'webp'])]
)
title = models.CharField(max_length=255)
uploaded_at = models.DateTimeField(auto_now_add=True)
file_size = models.PositiveIntegerField() # Size in bytes
class Meta:
ordering = ['-uploaded_at']
def __str__(self):
return f"{self.title} - {self.user.username}"
@property
def url(self):
return self.image.url if self.image else None
def save(self, *args, **kwargs):
if not self.file_size and self.image:
self.file_size = self.image.size
super().save(*args, **kwargs)
@classmethod
def get_user_daily_upload_size(cls, user):
today = timezone.now().date()
tomorrow = today + timezone.timedelta(days=1)
return cls.objects.filter(
user=user,
uploaded_at__gte=today,
uploaded_at__lt=tomorrow
).aggregate(total_size=models.Sum('file_size'))['total_size'] or 0
class Tag(models.Model):
name = models.CharField(max_length=255)
slug = models.SlugField(unique=True)
def __str__(self):
return self.name

View File

@@ -0,0 +1,115 @@
from rest_framework import serializers
from .models import Post, Comment, Page, Category, UploadedImage, Tag
from users.models import User
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ['id', 'name', 'slug']
read_only_fields = ['id']
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = ['id', 'name', 'slug']
read_only_fields = ['id']
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'first_name', 'last_name']
read_only_fields = ['id']
class CommentSerializer(serializers.ModelSerializer):
author = UserSerializer(read_only=True)
class Meta:
model = Comment
fields = ['id', 'content', 'author', 'post', 'page', 'approved', 'created_at']
read_only_fields = ['id', 'created_at', 'author']
class PostSerializer(serializers.ModelSerializer):
author = UserSerializer(read_only=True)
category = CategorySerializer(read_only=True)
category_id = serializers.PrimaryKeyRelatedField(
queryset=Category.objects.all(),
source='category',
write_only=True
)
comments = CommentSerializer(many=True, read_only=True)
tags = TagSerializer(many=True, read_only=True)
tag_ids = serializers.PrimaryKeyRelatedField(
queryset=Tag.objects.all(),
source='tags',
many=True,
write_only=True,
required=False
)
slug = serializers.SlugField(required=True)
class Meta:
model = Post
fields = [
'id', 'title', 'content', 'status', 'author',
'category', 'category_id', 'comments',
'created_at', 'updated_at', 'tags', 'tag_ids', 'slug'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'author', 'slug']
lookup_field = 'slug'
extra_kwargs = {
'url': {'lookup_field': 'slug'}
}
class PageSerializer(serializers.ModelSerializer):
author = UserSerializer(read_only=True)
category = CategorySerializer(read_only=True)
category_id = serializers.PrimaryKeyRelatedField(
queryset=Category.objects.all(),
source='category',
write_only=True
)
comments = CommentSerializer(many=True, read_only=True)
slug = serializers.SlugField(required=True)
class Meta:
model = Page
fields = [
'id', 'title', 'content', 'status', 'author',
'category', 'category_id', 'comments',
'created_at', 'updated_at', 'slug'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'author', 'slug']
lookup_field = 'slug'
extra_kwargs = {
'url': {'lookup_field': 'slug'}
}
class UploadedImageSerializer(serializers.ModelSerializer):
url = serializers.SerializerMethodField()
file_size_display = serializers.SerializerMethodField()
image = serializers.ImageField(required=True)
class Meta:
model = UploadedImage
fields = ['id', 'title', 'image', 'url', 'uploaded_at', 'file_size', 'file_size_display']
read_only_fields = ['file_size', 'uploaded_at', 'url']
def get_url(self, obj):
request = self.context.get('request')
if obj.image and hasattr(obj.image, 'url'):
return request.build_absolute_uri(obj.image.url) if request else obj.image.url
return None
def get_file_size_display(self, obj):
"""Convert bytes to human readable format"""
for unit in ['B', 'KB', 'MB', 'GB']:
if obj.file_size < 1024.0:
return f"{obj.file_size:.1f} {unit}"
obj.file_size /= 1024.0
return f"{obj.file_size:.1f} TB"
def validate_image(self, value):
if not value:
raise serializers.ValidationError("Image file is required.")
return value

3
backend/content/tests.py Normal file
View File

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

14
backend/content/urls.py Normal file
View File

@@ -0,0 +1,14 @@
from rest_framework.routers import DefaultRouter
from django.urls import path, include
from .views import PostViewSet, CommentViewSet, PageViewSet, CategoryViewSet, UploadedImageViewSet, TagViewSet
router = DefaultRouter()
router.register(r'posts', PostViewSet, basename='post')
router.register(r'comments', CommentViewSet, basename='comment')
router.register(r'pages', PageViewSet, basename='page')
router.register(r'categories', CategoryViewSet, basename='category')
router.register(r'images', UploadedImageViewSet, basename='image')
router.register(r'tags', TagViewSet, basename='tag')
urlpatterns = [
path('', include(router.urls)),
]

202
backend/content/views.py Normal file
View File

@@ -0,0 +1,202 @@
from rest_framework import viewsets, status, filters
from rest_framework.response import Response
from rest_framework.decorators import action
from django_filters.rest_framework import DjangoFilterBackend
from .models import Post, Comment, Page, Category, UploadedImage, Tag
from .serializers import PostSerializer, CommentSerializer, PageSerializer, CategorySerializer, UploadedImageSerializer, TagSerializer
from users.permissions import IsAuthorOrReadOnly, IsEditorOrAdmin
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.parsers import MultiPartParser, FormParser
from django.conf import settings
from rest_framework import serializers
class PostViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing blog posts.
list:
Return a list of all posts. Results can be filtered by:
- status (draft/pending/published)
- author
- category
- created_at (date range)
create:
Create a new post. Requires authentication and appropriate permissions.
retrieve:
Return the details of a specific post by ID.
update:
Update all fields of a specific post. Requires authentication and appropriate permissions.
partial_update:
Update one or more fields of a specific post. Requires authentication and appropriate permissions.
destroy:
Delete a specific post. Requires authentication and appropriate permissions.
"""
queryset = Post.objects.all()
serializer_class = PostSerializer
permission_classes = [IsAuthorOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['status', 'author', 'category']
search_fields = ['title', 'content']
ordering_fields = ['created_at', 'updated_at', 'title']
ordering = ['-created_at']
lookup_field = 'slug'
def perform_create(self, serializer):
serializer.save(author=self.request.user)
def get_queryset(self):
queryset = Post.objects.all()
if not self.request.user.is_authenticated:
queryset = queryset.filter(status='published')
elif not self.request.user.role in ['admin', 'moderator']:
queryset = queryset.filter(
status='published'
) | Post.objects.filter(
author=self.request.user
)
return queryset
def perform_update(self, serializer):
print(serializer.validated_data)
return super().perform_update(serializer)
class CommentViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing comments on posts and pages.
list:
Return a list of all comments. Results can be filtered by:
- post_id
- page_id
- author
- approved status
create:
Create a new comment. Requires authentication.
retrieve:
Return the details of a specific comment by ID.
update:
Update all fields of a specific comment. Requires authentication and appropriate permissions.
partial_update:
Update one or more fields of a specific comment. Requires authentication and appropriate permissions.
destroy:
Delete a specific comment. Requires authentication and appropriate permissions.
"""
queryset = Comment.objects.all()
serializer_class = CommentSerializer
permission_classes = [IsAuthorOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ['post', 'page', 'author', 'approved']
ordering_fields = ['created_at']
ordering = ['-created_at']
def perform_create(self, serializer):
serializer.save(author=self.request.user)
def get_queryset(self):
queryset = Comment.objects.all()
if not self.request.user.is_authenticated:
queryset = queryset.filter(approved=True)
elif not self.request.user.role in ['admin', 'moderator']:
queryset = queryset.filter(
approved=True
) | Comment.objects.filter(
author=self.request.user
)
return queryset
class PageViewSet(viewsets.ModelViewSet):
queryset = Page.objects.all()
serializer_class = PageSerializer
permission_classes = [IsEditorOrAdmin]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['status', 'author', 'category']
search_fields = ['title', 'content']
ordering_fields = ['created_at', 'updated_at', 'title']
ordering = ['-created_at']
def perform_create(self, serializer):
serializer.save(author=self.request.user)
def get_queryset(self):
queryset = Page.objects.all()
if not self.request.user.is_authenticated:
queryset = queryset.filter(status='published')
elif not self.request.user.role in ['admin', 'moderator']:
queryset = queryset.filter(
status='published'
) | Page.objects.filter(
author=self.request.user
)
return queryset
class CategoryViewSet(viewsets.ModelViewSet):
queryset = Category.objects.all()
serializer_class = CategorySerializer
permission_classes = [IsAuthenticatedOrReadOnly]
filter_backends = [filters.SearchFilter]
search_fields = ['name', 'slug']
def get_queryset(self):
return Category.objects.all()
class UploadedImageViewSet(viewsets.ModelViewSet):
serializer_class = UploadedImageSerializer
parser_classes = (MultiPartParser, FormParser)
def get_queryset(self):
return UploadedImage.objects.filter(user=self.request.user)
def get_serializer_context(self):
context = super().get_serializer_context()
context['request'] = self.request
return context
def perform_create(self, serializer):
# Check daily upload quota (5MB = 5 * 1024 * 1024 bytes)
DAILY_UPLOAD_LIMIT = 5 * 1024 * 1024 # 5MB in bytes
# Get current daily upload size
current_size = UploadedImage.get_user_daily_upload_size(self.request.user)
file_size = self.request.FILES['image'].size
# Check if upload would exceed daily limit
if current_size + file_size > DAILY_UPLOAD_LIMIT:
raise serializers.ValidationError({
'image': 'Daily upload limit (5MB) exceeded. Please try again tomorrow.'
})
try:
instance = serializer.save(user=self.request.user, file_size=file_size)
except Exception as e:
raise
@action(detail=False, methods=['get'])
def quota(self, request):
DAILY_UPLOAD_LIMIT = 5 * 1024 * 1024 # 5MB in bytes
current_size = UploadedImage.get_user_daily_upload_size(request.user)
remaining = max(0, DAILY_UPLOAD_LIMIT - current_size)
return Response({
'daily_limit': DAILY_UPLOAD_LIMIT,
'used': current_size,
'remaining': remaining,
'daily_limit_display': f"{DAILY_UPLOAD_LIMIT / (1024 * 1024):.1f}MB",
'used_display': f"{current_size / (1024 * 1024):.1f}MB",
'remaining_display': f"{remaining / (1024 * 1024):.1f}MB"
})
class TagViewSet(viewsets.ModelViewSet):
queryset = Tag.objects.all()
serializer_class = TagSerializer
permission_classes = [IsAuthenticatedOrReadOnly]
filter_backends = [filters.SearchFilter]
search_fields = ['name', 'slug']