diff --git a/.gitignore b/.gitignore index 86809b7..9d4e667 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,14 @@ dmypy.json # vscode .vscode/ + +# asset/media/static +asset/ +media/ +static/ + + +# pytest cache +.pytest_cache/ +htmlcov/ +.coveragerc \ No newline at end of file diff --git a/BaseDRF/models.py b/BaseDRF/models.py new file mode 100644 index 0000000..e61b847 --- /dev/null +++ b/BaseDRF/models.py @@ -0,0 +1,15 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class TimestampModel(models.Model): + created_at = models.DateTimeField( + _("created at"), + auto_now_add=True + ) + updated_at = models.DateTimeField( + _("updated at"), + auto_now=True + ) + class Meta: + abstract = True \ No newline at end of file diff --git a/BaseDRF/settings.py b/BaseDRF/settings.py index 56c5e62..00ea536 100644 --- a/BaseDRF/settings.py +++ b/BaseDRF/settings.py @@ -11,6 +11,7 @@ """ from pathlib import Path +import os # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve(strict=True).parent.parent @@ -37,8 +38,15 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "rest_framework", "rest_framework_simplejwt", + "django_filters", "user", + "mptt", + "blog", + "imagekit", + "ckeditor", + "ckeditor_uploader", ] MIDDLEWARE = [ @@ -120,6 +128,26 @@ # https://docs.djangoproject.com/en/3.1/howto/static-files/ STATIC_URL = "/static/" +STATIC_ROOT = os.path.join(BASE_DIR, "asset") +STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) + +# Media +MEDIA_ROOT = os.path.join(BASE_DIR, "media") +MEDIA_URL = "/media/" + + +REST_FRAMEWORK = { + "DEFAULT_PERMISSION_CLASSES": [ + # 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' + ] +} + +IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND = "path.to.MyImageCacheBackend" + +SITE_PROTOCOL = "http" + +CKEDITOR_BASEPATH = "/static/ckeditor/ckeditor/" +CKEDITOR_UPLOAD_PATH = "uploads/" AUTH_USER_MODEL = "user.User" @@ -128,9 +156,13 @@ REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework_simplejwt.authentication.JWTAuthentication", - ] + "rest_framework.authentication.BasicAuthentication", + "rest_framework.authentication.SessionAuthentication", + ], + 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'] } OTP_EXPIRE_TIME = 60 EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + diff --git a/BaseDRF/urls.py b/BaseDRF/urls.py index b60ceef..93cfdc8 100644 --- a/BaseDRF/urls.py +++ b/BaseDRF/urls.py @@ -1,13 +1,35 @@ from django.contrib import admin from django.urls import path, include +from django.conf.urls.static import static +from django.conf import settings from rest_framework_simplejwt.views import ( TokenRefreshView, ) from user.views import MyTokenObtainPairView -urlpatterns = [ - path("admin/", admin.site.urls), - path("auth/", include("auth_user.urls")), - path("api/token/", MyTokenObtainPairView.as_view(), name="token_obtain_pair"), - path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), -] +from django.views.decorators.csrf import csrf_exempt +from blog.views import MyImageUploadView + +from rest_framework import routers + +router = routers.DefaultRouter() + + +urlpatterns = ( + [ + path("admin/", admin.site.urls), + path("auth/", include("auth_user.urls")), + path("blog/", include("blog.urls")), + path("api-auth/", include("rest_framework.urls")), + path("api/token/", MyTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path( + "ckeditor/upload/", + csrf_exempt(MyImageUploadView.as_view()), + name="ckeditor_upload", + ), + path("ckeditor/", include("ckeditor_uploader.urls")), + ] + + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) +) diff --git a/blog/__init__.py b/blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blog/admin.py b/blog/admin.py new file mode 100644 index 0000000..aa4223c --- /dev/null +++ b/blog/admin.py @@ -0,0 +1,32 @@ +from django.contrib import admin +from .models import PostComment, Post, BlogCategory +from mptt.admin import MPTTModelAdmin + + +@admin.register(BlogCategory) +class BlogCategoryAdmin(MPTTModelAdmin): + mptt_level_indent = 20 + list_display = ("id", "name", "parent") + list_display_link = "id" + list_editable = ("parent", "name") + sortable = "-id" + + +@admin.register(Post) +class PostAdmin(admin.ModelAdmin): + list_display = ( + "id", + "title", + "author", + "is_published", + "category", + ) + list_editable = ("title", "author", "is_published", "category") + + +@admin.register(PostComment) +class PostCommentAdmin(MPTTModelAdmin): + mptt_level_indent = 20 + list_display = ("id", "author", "post", "is_published", "parent") + list_editable = ("author", "post", "is_published", "parent") + sortable = "-id" diff --git a/blog/apps.py b/blog/apps.py new file mode 100644 index 0000000..7930587 --- /dev/null +++ b/blog/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class BlogConfig(AppConfig): + name = 'blog' diff --git a/blog/filters.py b/blog/filters.py new file mode 100644 index 0000000..4e9ad40 --- /dev/null +++ b/blog/filters.py @@ -0,0 +1,45 @@ +import django_filters +from .models import PostComment, Post, BlogCategory +from django_filters import rest_framework as filters + + +class PostFilter(filters.FilterSet): + category_name = django_filters.CharFilter( + lookup_expr="contains", field_name="category__name", label="category name" + ) + author_username = django_filters.CharFilter( + lookup_expr="contains", field_name="author__username", label="author username" + ) + + class Meta: + model = Post + fields = [ + "title", + "slug", + "author", + "author_username", + "category", + "category_name", + "is_published", + ] + + +class PostCommentFilter(filters.FilterSet): + author_username = django_filters.CharFilter( + lookup_expr="contains", field_name="author__username", label="author username" + ) + post_author = django_filters.CharFilter( + lookup_expr="contains", field_name="post__author", label="post author" + ) + post_is_published = django_filters.BooleanFilter( + field_name="post__is_published", label="post is published" + ) + post_category_name = django_filters.CharFilter( + lookup_expr="contains", + field_name="post__category__name", + label="post category name", + ) + + class Meta: + model = PostComment + fields = "__all__" diff --git a/blog/migrations/0001_initial.py b/blog/migrations/0001_initial.py new file mode 100644 index 0000000..4d0b08a --- /dev/null +++ b/blog/migrations/0001_initial.py @@ -0,0 +1,72 @@ +# Generated by Django 3.1 on 2021-10-15 13:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields +import tinymce.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=500, unique=True)), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='blog.category')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Post', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('title', models.CharField(max_length=1000, unique=True, verbose_name='title of post')), + ('slug', models.SlugField(blank=True, max_length=1000, null=True, unique=True, verbose_name='slug of post')), + ('image', models.ImageField(blank='True', null='True', upload_to='PostImages/', verbose_name='title image')), + ('content', tinymce.models.HTMLField(blank=True, null=True, verbose_name='content of post, use one just h1 for header in content')), + ('is_published', models.BooleanField(verbose_name='can publish post?')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author')), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='blog.category', verbose_name='category of post')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='PostComment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('content', models.TextField(verbose_name='text of post')), + ('is_published', models.BooleanField(default=True, verbose_name='can publish comment?')), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author')), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='blog.postcomment')), + ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.post', verbose_name='post')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/blog/migrations/0002_auto_20211112_1455.py b/blog/migrations/0002_auto_20211112_1455.py new file mode 100644 index 0000000..9201f8b --- /dev/null +++ b/blog/migrations/0002_auto_20211112_1455.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2021-11-12 14:55 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0001_initial'), + ] + + operations = [ + migrations.RenameModel( + old_name='Category', + new_name='BlogCategory', + ), + ] diff --git a/blog/migrations/0003_post_content_b.py b/blog/migrations/0003_post_content_b.py new file mode 100644 index 0000000..69a37d5 --- /dev/null +++ b/blog/migrations/0003_post_content_b.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1 on 2021-11-12 16:23 + +import ckeditor.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0002_auto_20211112_1455'), + ] + + operations = [ + migrations.AddField( + model_name='post', + name='content_b', + field=ckeditor.fields.RichTextField(blank=True, null=True), + ), + ] diff --git a/blog/migrations/0004_auto_20211112_1625.py b/blog/migrations/0004_auto_20211112_1625.py new file mode 100644 index 0000000..08f63cf --- /dev/null +++ b/blog/migrations/0004_auto_20211112_1625.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1 on 2021-11-12 16:25 + +import ckeditor_uploader.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0003_post_content_b'), + ] + + operations = [ + migrations.AlterField( + model_name='post', + name='content_b', + field=ckeditor_uploader.fields.RichTextUploadingField(blank=True, null=True), + ), + ] diff --git a/blog/migrations/0005_remove_post_content_b.py b/blog/migrations/0005_remove_post_content_b.py new file mode 100644 index 0000000..375f3ca --- /dev/null +++ b/blog/migrations/0005_remove_post_content_b.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2021-11-17 10:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0004_auto_20211112_1625'), + ] + + operations = [ + migrations.RemoveField( + model_name='post', + name='content_b', + ), + ] diff --git a/blog/migrations/0006_auto_20211117_1041.py b/blog/migrations/0006_auto_20211117_1041.py new file mode 100644 index 0000000..7eec853 --- /dev/null +++ b/blog/migrations/0006_auto_20211117_1041.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1 on 2021-11-17 10:41 + +import ckeditor_uploader.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0005_remove_post_content_b'), + ] + + operations = [ + migrations.AlterField( + model_name='post', + name='content', + field=ckeditor_uploader.fields.RichTextUploadingField(blank=True, null=True, verbose_name='content of post, use one just h1 for header in content'), + ), + ] diff --git a/blog/migrations/__init__.py b/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blog/models.py b/blog/models.py new file mode 100644 index 0000000..08f35d4 --- /dev/null +++ b/blog/models.py @@ -0,0 +1,93 @@ +import imagekit +from BaseDRF.models import TimestampModel +from django.db import models + +from django.utils.translation import gettext_lazy as _ +from django.utils.text import slugify + +from imagekit.models import ImageSpecField +from imagekit.processors import ResizeToFill +from mptt.models import MPTTModel, TreeForeignKey +from BaseDRF import settings +from ckeditor_uploader.fields import RichTextUploadingField + + +class BlogCategory(MPTTModel): + name = models.CharField(max_length=500, unique=True) + parent = TreeForeignKey( + "self", on_delete=models.CASCADE, null=True, blank=True, related_name="children" + ) + + class MPTTMeta: + order_insertion_by = ["name"] + + def __str__(self) -> str: + return self.name + + +class Post(TimestampModel): + title = models.CharField(_("title of post"), max_length=1000, unique=True) + slug = models.SlugField( + _("slug of post"), max_length=1000, unique=True, null=True, blank=True + ) + author = models.ForeignKey( + settings.AUTH_USER_MODEL, verbose_name=_("author"), on_delete=models.CASCADE + ) + image = models.ImageField( + _("title image"), upload_to="PostImages/", null="True", blank="True" + ) + thumbnail_image = ImageSpecField( + source="image", + processors=[ResizeToFill(200, 200)], + format="webp", + options={"quality": 60}, + ) + webp_image = ImageSpecField( + source="image", + # processors=[ResizeToFill(1280, 720)], + format="webp", + options={"quality": 70}, + ) + content = RichTextUploadingField( + _("content of post, use one just h1 for header in content"), + blank=True, + null=True, + ) + is_published = models.BooleanField(_("can publish post?")) + category = models.ForeignKey( + "blog.BlogCategory", + verbose_name=_("category of post"), + on_delete=models.CASCADE, + null=True, + blank=True, + ) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return self.title + + def save(self, *args, **kwargs): + self.slug = slugify(self.title) + super(Post, self).save(*args, **kwargs) + + +class PostComment(TimestampModel, MPTTModel): + """ + comment of post + """ + + author = models.ForeignKey( + settings.AUTH_USER_MODEL, verbose_name=_("author"), on_delete=models.CASCADE + ) + post = models.ForeignKey("blog.Post", verbose_name="post", on_delete=models.CASCADE) + content = models.TextField(_("text of post")) + is_published = models.BooleanField(_("can publish comment?"), default=True) + parent = TreeForeignKey( + "self", on_delete=models.CASCADE, null=True, blank=True, related_name="children" + ) + + def __str__(self) -> str: + name = str(self.author.username) + "/" + str(self.post.title) + return name diff --git a/blog/serializers.py b/blog/serializers.py new file mode 100644 index 0000000..ec0c545 --- /dev/null +++ b/blog/serializers.py @@ -0,0 +1,45 @@ +from rest_framework import serializers +from .models import Post, BlogCategory, PostComment + + +class PostModelSerializer(serializers.ModelSerializer): + thumbnail = serializers.SerializerMethodField("get_thumbnail_image") + webp_image = serializers.SerializerMethodField("get_webp_image") + author_username = serializers.CharField(source="author.username", read_only=True) + author_first_name = serializers.CharField( + source="author.first_name", read_only=True + ) + author_last_name = serializers.CharField(source="author.last_name", read_only=True) + category_name = serializers.CharField(source="category.name", read_only=True) + + def get_thumbnail_image(self, obj): + if obj.thumbnail_image: + thumbnail = obj.thumbnail_image.url + return thumbnail + + def get_webp_image(self, obj): + if obj.webp_image: + webp = obj.webp_image.url + return webp + + class Meta: + model = Post + exclude = ("image",) + + +class BlogCategorySerializer(serializers.ModelSerializer): + class Meta: + model = BlogCategory + fields = "__all__" + + +class PostCommentSerializer(serializers.ModelSerializer): + author_username = serializers.CharField(source="author.usesrname", read_only=True) + author_first_name = serializers.CharField( + source="author.first_name", read_only=True + ) + author_last_name = serializers.CharField(source="author.last_name", read_only=True) + + class Meta: + model = PostComment + fields = "__all__" diff --git a/auth_user/tests.py b/blog/tests.py old mode 100755 new mode 100644 similarity index 56% rename from auth_user/tests.py rename to blog/tests.py index 7ce503c..9b15ff0 --- a/auth_user/tests.py +++ b/blog/tests.py @@ -1,3 +1,3 @@ from django.test import TestCase -# Create your tests here. + diff --git a/blog/urls.py b/blog/urls.py new file mode 100644 index 0000000..8ded2ac --- /dev/null +++ b/blog/urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from . import views +from rest_framework import routers + +router = routers.SimpleRouter() + +router.register(r"blog-category", views.BlogCategoryViewSet, basename="blog_category") +router.register(r"post", views.PostModelViewSet, basename="post") +router.register(r"post-comment", views.PostComment, basename="post_comment") + +urlpatterns = router.urls diff --git a/blog/views.py b/blog/views.py new file mode 100644 index 0000000..e5deeb2 --- /dev/null +++ b/blog/views.py @@ -0,0 +1,64 @@ +from rest_framework.viewsets import ModelViewSet + +from .serializers import ( + PostCommentSerializer, + PostModelSerializer, + BlogCategorySerializer, +) +from .models import BlogCategory, Post, PostComment +from .filters import PostFilter, PostCommentFilter + +from ckeditor_uploader.views import ImageUploadView +from BaseDRF import settings +import json +import re +import os +from PIL import Image + + +class BlogCategoryViewSet(ModelViewSet): + serializer_class = BlogCategorySerializer + queryset = Post.objects.select_related("parent").all() + + +class PostModelViewSet(ModelViewSet): + serializer_class = PostModelSerializer + queryset = ( + Post.objects.filter(is_published=True) + .select_related("author", "category") + .all() + ) + filterset_class = PostFilter + + +class PostComment(ModelViewSet): + serializer_class = PostCommentSerializer + queryset = ( + PostComment.objects.filter(is_published=True) + .select_related("author", "post", "parent") + .all() + ) + filterset_class = PostCommentFilter + + +class MyImageUploadView(ImageUploadView): + def post(self, request, **kwargs): + base_process = super(MyImageUploadView, self).post(request, **kwargs) + + base_image = json.loads(base_process.content) + file_name_path = re.findall(r"^[^\.]*", base_image["url"])[0] + + image = Image.open(str(settings.BASE_DIR) + base_image["url"]) + image = image.convert("RGB") + image.save(str(settings.BASE_DIR) + file_name_path + ".webp", "webp") + + os.remove(str(settings.BASE_DIR) + base_image["url"]) + + new_image_url = file_name_path + ".webp" + base_image["url"] = new_image_url + base_image["fileName"] = ( + re.findall(r".+(\/.+)$", file_name_path)[0][1:] + ".webp" + ) + changed_json = json.dumps(base_image).encode("ascii") + base_process.content = changed_json + return base_process diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..0732be6 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = BaseDRF.settings +addopts = -v -s --nomigrations --cov=. --cov-report=html --no-cov-on-fail --ignore=venv diff --git a/requirements.txt b/requirements.txt index 1c0373d..92b1a09 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,17 @@ asgiref==3.2.10 Django==3.1 pytz==2021.1 sqlparse==0.4.1 +Pillow==8.3.2 +djangorestframework==3.12.4 +markdown==3.3.4 +django-filter==21.1 +django-tinymce==3.3.0 +django-imagekit==4.1.0 +django-mptt==0.13.4 +django-ckeditor==6.1.0 +djangorestframework-simplejwt==5.0.0 +pyotp==2.6.0 +pytest==6.2.5 +pytest-django==4.4.0 +pytest-cov==3.0.0 +mixer==7.2.0 \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/blog/__init__.py b/tests/blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/blog/test_blog.py b/tests/blog/test_blog.py new file mode 100644 index 0000000..5871ed8 --- /dev/null +++ b/tests/blog/test_blog.py @@ -0,0 +1 @@ +import pytest diff --git a/user/migrations/0004_auto_20211119_1627.py b/user/migrations/0004_auto_20211119_1627.py new file mode 100644 index 0000000..16ae3d0 --- /dev/null +++ b/user/migrations/0004_auto_20211119_1627.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2021-11-19 16:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0003_auto_20210917_0141'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='username', + field=models.CharField(blank=True, max_length=45, null=True, unique=True), + ), + ] diff --git a/user/tests.py b/user/tests.py deleted file mode 100755 index 7ce503c..0000000 --- a/user/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here.