diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..af7249d --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,278 @@ +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +staticfiles/ + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# Environments +.venv +venv/ +ENV/ + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + + +### Node template +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + + +### Linux template +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + + +### VisualStudioCode template +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for devcontainer +.devcontainer/bash_history + + + + +### Windows template +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + + +### macOS template +# General +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + + +### SublimeText template +# Cache files for Sublime Text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# Workspace files are user-specific +*.sublime-workspace + +# Project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using Sublime Text +# *.sublime-project + +# SFTP configuration file +sftp-config.json + +# Package control specific files +Package Control.last-run +Package Control.ca-list +Package Control.ca-bundle +Package Control.system-ca-bundle +Package Control.cache/ +Package Control.ca-certs/ +Package Control.merged-ca-bundle +Package Control.user-ca-bundle +oscrypto-ca-bundle.crt +bh_unicode_properties.cache + +# Sublime-github package stores a github token in this file +# https://packagecontrol.io/packages/sublime-github +GitHub.sublime-settings + + +### Vim template +# Swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-v][a-z] +[._]sw[a-p] + +# Session +Session.vim + +# Temporary +.netrwhist + +# Auto-generated tag files +tags + +# Redis dump file +dump.rdb + +### Project template +# e_commerce/media/ + +.pytest_cache/ +.ipython/ \ No newline at end of file diff --git a/backend/Inspire_Ink/__init__.py b/backend/Inspire_Ink/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/Inspire_Ink/asgi.py b/backend/Inspire_Ink/asgi.py new file mode 100644 index 0000000..e660820 --- /dev/null +++ b/backend/Inspire_Ink/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for Inspire_Ink project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Inspire_Ink.settings') + +application = get_asgi_application() diff --git a/backend/Inspire_Ink/settings.py b/backend/Inspire_Ink/settings.py new file mode 100644 index 0000000..d46fc28 --- /dev/null +++ b/backend/Inspire_Ink/settings.py @@ -0,0 +1,171 @@ +""" +Django settings for Inspire_Ink project. + +Generated by 'django-admin startproject' using Django 5.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-42klz+7n0(j7@13+0%=-5nd@i)^3g-t7(br-3drg0u%xg9%a&r' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'rest_framework', + 'rest_framework_simplejwt', + 'djoser', + "corsheaders", + + "app", +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + "corsheaders.middleware.CorsMiddleware", + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'Inspire_Ink.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'Inspire_Ink.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + +AUTH_USER_MODEL = 'app.User' + +# Password validation +# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.1/howto/static-files/ + +STATIC_URL = 'static/' + +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / "media" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + + + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ) +} + +from datetime import timedelta +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'ROTATE_REFRESH_TOKENS': False, + 'BLACKLIST_AFTER_ROTATION': True, + # 'AUTH_HEADER_TYPES': ('JWT',), +} + + +CORS_ALLOW_ALL_ORIGINS = True +CORS_ALLOW_METHODS = ( + "DELETE", + "GET", + "OPTIONS", + "PATCH", + "POST", + "PUT", +) +CORS_ALLOW_HEADERS = ( + "accept", + "authorization", + "content-type", + "user-agent", + "x-csrftoken", + "x-requested-with", +) \ No newline at end of file diff --git a/backend/Inspire_Ink/urls.py b/backend/Inspire_Ink/urls.py new file mode 100644 index 0000000..b2741d0 --- /dev/null +++ b/backend/Inspire_Ink/urls.py @@ -0,0 +1,33 @@ +""" +URL configuration for Inspire_Ink project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include, re_path +from django.conf import settings +from django.conf.urls.static import static + + +urlpatterns = [ + path('admin/', admin.site.urls), + re_path(r'^auth/', include('djoser.urls')), + re_path(r'^auth/', include('djoser.urls.jwt')), + + path('app/', include('app.urls')), +] + +if settings.DEBUG: + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/backend/Inspire_Ink/wsgi.py b/backend/Inspire_Ink/wsgi.py new file mode 100644 index 0000000..c3ad5ef --- /dev/null +++ b/backend/Inspire_Ink/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for Inspire_Ink project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Inspire_Ink.settings') + +application = get_wsgi_application() diff --git a/backend/app/README.md b/backend/app/README.md new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/admin.py b/backend/app/admin.py new file mode 100644 index 0000000..cd3c249 --- /dev/null +++ b/backend/app/admin.py @@ -0,0 +1,43 @@ +from django.contrib import admin +from .models import User, Category, Article, Comment, Like, Notification + +@admin.register(User) +class UserAdmin(admin.ModelAdmin): + list_display = ('username', 'email', 'is_staff', 'is_active', 'date_joined') + search_fields = ('username', 'email') + list_filter = ('is_staff', 'is_active') + ordering = ('date_joined',) + +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + list_display = ('name', 'description', 'created_at') + search_fields = ('name',) + ordering = ('created_at',) + + +@admin.register(Article) +class ArticleAdmin(admin.ModelAdmin): + list_display = ('title', 'author', 'category', 'created_at', 'updated_at') + search_fields = ('title', 'author__username') + list_filter = ('category',) + ordering = ('created_at',) + autocomplete_fields = ('author', 'category') + +@admin.register(Comment) +class CommentAdmin(admin.ModelAdmin): + list_display = ('content', 'author', 'article', 'created_at', 'id') + search_fields = ('content', 'author__username', 'article__title') + ordering = ('created_at',) + +@admin.register(Like) +class LikeAdmin(admin.ModelAdmin): + list_display = ('user', 'article', 'created_at') + search_fields = ('user__username', 'article__title') + ordering = ('created_at',) + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + list_display = ('recipient', 'content', 'created_at', 'is_read') + search_fields = ('recipient__username', 'content') + list_filter = ('is_read',) + ordering = ('created_at',) diff --git a/backend/app/apps.py b/backend/app/apps.py new file mode 100644 index 0000000..ed327d2 --- /dev/null +++ b/backend/app/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AppConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'app' diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..b992ce5 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,104 @@ +from django.db import models +from django.contrib.auth.models import AbstractUser +import uuid + +class User(AbstractUser): + """ + Represents a user of the blog application. Extends the default Django AbstractUser to include additional fields. + """ + bio = models.TextField(null=True, blank=True, help_text="A short biography of the user.") + profile_image = models.ImageField(upload_to="profile_images/", null=True, blank=True, help_text="Profile image of the user.") + followers = models.ManyToManyField("self", symmetrical=False, related_name="following", blank=True, help_text="Users who follow this user.") + + def __str__(self): + """Returns the username of the user.""" + return self.username + + + +class Article(models.Model): + """ + Represents an article written by a user. + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles", help_text="The author of the article.") + title = models.CharField(max_length=50, help_text="The title of the article.") + introduction = models.CharField(max_length=300, help_text="The introduction of the article.") + content = models.TextField(null=True, blank=True, help_text="The content of the article.") + thumbnail = models.ImageField(upload_to="article_thumbnails/", null=True, blank=True, help_text="An optional thumbnail image for the article.") + created_at = models.DateTimeField(auto_now_add=True, help_text="The timestamp when the article was created.") + updated_at = models.DateTimeField(auto_now=True, help_text="The timestamp when the article was last updated.") + category = models.ForeignKey("Category", on_delete=models.SET_NULL, null=True, blank=True, related_name="articles", help_text="The category of the article.") + + def __str__(self): + """Returns the title of the article.""" + return self.title + + +class Category(models.Model): + """ + Represents a category to organize articles. + """ + name = models.CharField(max_length=100, unique=True, help_text="The unique name of the category.") + description = models.TextField(null=True, blank=True, help_text="A short description of the category.") + created_at = models.DateTimeField(auto_now_add=True, help_text="The timestamp when the category was created.") + + def __str__(self): + """Returns the name of the category.""" + return self.name + + +class Comment(models.Model): + """ + Represents a comment on an article. + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name="comments", help_text="The article this comment belongs to.") + author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="comments", help_text="The author of the comment.") + content = models.TextField(help_text="The content of the comment.") + created_at = models.DateTimeField(auto_now_add=True, help_text="The timestamp when the comment was created.") + + def __str__(self): + """Returns a short description of the comment.""" + return f"Comment by {self.author.username} on {self.article.title}" + + +class Like(models.Model): + """ + Represents a like for an article or a comment. + """ + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="user_likes", help_text="The user who liked the article or comment.") + article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name="article_likes", null=True, blank=True, help_text="The article that was liked, if applicable.") + created_at = models.DateTimeField(auto_now_add=True, help_text="The timestamp when the like was created.") + + def __str__(self): + """Returns a short description of the like.""" + if self.article: + return f"{self.user.username} liked {self.article.title}" + elif self.comment: + return f"{self.user.username} liked a comment" + + +class Notification(models.Model): + """ + Represents a notification for a user. + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + recipient = models.ForeignKey(User, on_delete=models.CASCADE, related_name="notifications", help_text="The user who will receive the notification.") + content = models.CharField(max_length=255, help_text="The content of the notification.") + created_at = models.DateTimeField(auto_now_add=True, help_text="The timestamp when the notification was created.") + is_read = models.BooleanField(default=False, help_text="Indicates whether the notification has been read.") + + def __str__(self): + """Returns a short description of the notification.""" + return f"Notification for {self.recipient.username}" + + +class View(models.Model): + article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name="views") + id_address = models.GenericIPAddressField(null=True, blank=True) + session_id = models.CharField(max_length=255, null=True, blank=True) + timestamp = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"View on {self.article.title}" \ No newline at end of file diff --git a/backend/app/serializers.py b/backend/app/serializers.py new file mode 100644 index 0000000..9d39bfb --- /dev/null +++ b/backend/app/serializers.py @@ -0,0 +1,83 @@ +from rest_framework import serializers +from .models import * +import os + + +class CommentSerializer(serializers.ModelSerializer): + + class Meta: + model = Comment + fields = "__all__" + read_only_fields = ["article", 'author', 'created_at'] + + + +class ArticleSerializer(serializers.ModelSerializer): + author = serializers.CharField(source='author.username', read_only=True) + image = serializers.SerializerMethodField() + comments = CommentSerializer(many=True, read_only=True) + likes_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Article + fields = "__all__" + read_only_fields = ['created_at', 'updated_at'] + + + def get_image(self, obj): + request = self.context.get('request') + if obj.thumbnail: + return request.build_absolute_uri(obj.thumbnail.url) + return None + + def validate_thumbnail(self, value): + valid_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] + ext = os.path.splitext(value.name)[1].lower() + if ext not in valid_extensions: + raise serializers.ValidationError("JPG, JPEG, PNG, GIF, WebP.") + return value + + def create(self, validated_data): + """ + Override the create method to use update_or_create. + """ + title = validated_data.get('title') + defaults = { + "introduction": validated_data.get('introduction'), + "content": validated_data.get('content'), + "author": self.context['request'].user, + "category": validated_data.get('category'), + "thumbnail": validated_data.get('thumbnail'), + } + article, created = Article.objects.update_or_create(author=self.context['request'].user, title=title, defaults=defaults) + return article, created + + +class ArticleListSerializer(serializers.ModelSerializer): + author = serializers.CharField(source='author.username', read_only=True) + likes_count = serializers.IntegerField(read_only=True) + image = serializers.SerializerMethodField() + + class Meta: + model = Article + fields = ["id", "author", "title", "image", "likes_count", 'created_at', 'updated_at'] + read_only_fields = ['created_at', 'updated_at'] + + def get_image(self, obj): + request = self.context.get('request') + if obj.thumbnail: + return request.build_absolute_uri(obj.thumbnail.url) + return None + + + + +class UserSerializer(serializers.ModelSerializer): + """ + Serializer for User model that includes the user's articles. + """ + articles = ArticleSerializer(many=True, read_only=True) + + class Meta: + model = User + fields = ['id', 'username', 'email', 'bio', 'profile_image', 'followers', 'articles'] \ No newline at end of file diff --git a/backend/app/tests/__init__.py b/backend/app/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/tests/test_models.py b/backend/app/tests/test_models.py new file mode 100644 index 0000000..d1a3076 --- /dev/null +++ b/backend/app/tests/test_models.py @@ -0,0 +1,113 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from app.models import Category, Tag, Article, Comment, Like, Notification + +User = get_user_model() + +class BlogModelsTest(TestCase): + + def setUp(self): + """ + Set up test data for all the models. + """ + self.user1 = User.objects.create_user(username="testuser1", password="password123", bio="Bio for user1") + self.user2 = User.objects.create_user(username="testuser2", password="password123") + + self.category = Category.objects.create(name="Technology", description="All about tech.") + + self.tag1 = Tag.objects.create(name="Python") + self.tag2 = Tag.objects.create(name="Django") + + self.article = Article.objects.create( + author=self.user1, + title="Test Article", + content="This is a test article.", + category=self.category, + ) + self.article.tags.add(self.tag1, self.tag2) + + # Create comment + self.comment = Comment.objects.create( + article=self.article, + author=self.user2, + content="This is a test comment." + ) + + # Create like + self.like = Like.objects.create(user=self.user2, article=self.article) + + # Create notification + self.notification = Notification.objects.create( + recipient=self.user1, + content="You have a new like on your article." + ) + + def test_user_creation(self): + """ + Test the User model and its additional fields. + """ + self.assertEqual(self.user1.bio, "Bio for user1") + self.assertEqual(self.user1.followers.count(), 0) + + def test_category_creation(self): + """ + Test the Category model. + """ + self.assertEqual(self.category.name, "Technology") + self.assertEqual(self.category.description, "All about tech.") + + def test_tag_creation(self): + """ + Test the Tag model. + """ + self.assertEqual(Tag.objects.count(), 2) + self.assertIn(self.tag1, self.article.tags.all()) + + def test_article_creation(self): + """ + Test the Article model. + """ + self.assertEqual(self.article.title, "Test Article") + self.assertEqual(self.article.author, self.user1) + self.assertEqual(self.article.category, self.category) + self.assertEqual(self.article.tags.count(), 2) + + def test_comment_creation(self): + """ + Test the Comment model. + """ + self.assertEqual(self.comment.article, self.article) + self.assertEqual(self.comment.author, self.user2) + self.assertEqual(self.comment.content, "This is a test comment.") + + def test_like_creation(self): + """ + Test the Like model. + """ + self.assertEqual(self.like.user, self.user2) + self.assertEqual(self.like.article, self.article) + self.assertIsNone(self.like.comment) + + def test_notification_creation(self): + """ + Test the Notification model. + """ + self.assertEqual(self.notification.recipient, self.user1) + self.assertEqual(self.notification.content, "You have a new like on your article.") + self.assertFalse(self.notification.is_read) + + def test_article_view_increment(self): + """ + Test manually incrementing the views for an article. + """ + self.article.views += 1 + self.article.save() + self.assertEqual(self.article.views, 1) + + def test_followers(self): + """ + Test the followers functionality. + """ + self.user1.followers.add(self.user2) + self.assertEqual(self.user1.followers.count(), 1) + self.assertIn(self.user2, self.user1.followers.all()) diff --git a/backend/app/urls.py b/backend/app/urls.py new file mode 100644 index 0000000..023f34b --- /dev/null +++ b/backend/app/urls.py @@ -0,0 +1,13 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('article/', views.ArticleView.as_view()), + path('article//', views.ArticleView.as_view()), # for Retrieve & Delete object + + path('comment//', views.CommentView.as_view()), + + path('like//', views.LikeView.as_view()), + + path('user/', views.UserDetailView.as_view()), +] diff --git a/backend/app/views.py b/backend/app/views.py new file mode 100644 index 0000000..dd8e065 --- /dev/null +++ b/backend/app/views.py @@ -0,0 +1,104 @@ +from rest_framework.views import APIView +from rest_framework import status +from rest_framework.response import Response +from django.shortcuts import get_object_or_404 +from django.db.models import Count +from .models import * +from .serializers import * +from rest_framework.permissions import IsAuthenticated +from django.utils.decorators import method_decorator + + +class ArticleView(APIView): + + def get(self, request, pk=None): + if pk: + article = get_object_or_404( + Article.objects + .select_related('author', 'category') + .prefetch_related('comments') + .annotate(likes_count=Count('article_likes')), + pk=pk + ) + serializer = ArticleSerializer(article, context={'request': request}) + return Response(serializer.data, status=status.HTTP_200_OK) + + articles = ( + Article.objects + .select_related('author', 'category') + .annotate(likes_count=Count('article_likes')) + ) + serializer = ArticleListSerializer(articles, many=True, context={'request': request}) + return Response(serializer.data, status=status.HTTP_200_OK) + + def post(self, request): + serializer = ArticleSerializer(data=request.data, context={"request": request}) + if serializer.is_valid(): + article, created = serializer.save() + if created: + return Response( + {"detail": "Article created successfully", "article_id": article.id}, + status=status.HTTP_201_CREATED, + ) + else: + return Response( + {"detail": "Article updated successfully", "article_id": article.id}, + status=status.HTTP_200_OK, + ) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, pk): + if not pk: + return Response(status=status.HTTP_404_NOT_FOUND) + + article = get_object_or_404(Article.objects.filter(author=request.user).select_related("author").only("id","author"), pk=pk) + article.delete() + return Response({"detail": "Article deleted successfully."}, status=status.HTTP_204_NO_CONTENT) + + + +class CommentView(APIView): + def post(self, request, pk): # pk for article + article = get_object_or_404(Article.objects.only('id'), pk=pk) + if Comment.objects.filter(article=article, author=request.user).exists(): + return Response({"detail": "You have already commented on this article."}, status=status.HTTP_400_BAD_REQUEST) + serializer = CommentSerializer(data= request.data) + + if serializer.is_valid(): + serializer.save(article=article, author=request.user) + return Response({"detail": "Comment created successfully"}, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + + def delete(self, request, pk): # pk for comment + comment = get_object_or_404(Comment.objects.filter(author=request.user).select_related("author").only('id', "author"), pk=pk) + comment.delete() + return Response({"detail": "Comment deleted successfully."}, status=status.HTTP_204_NO_CONTENT) + + + +class LikeView(APIView): + def post(self, request, pk): # pk for article + article = get_object_or_404(Article.objects.only('id'), pk=pk) + + # Try to delete the like if it exists, otherwise create it + like_deleted, _ = Like.objects.filter(article=article, user=request.user).delete() + + if like_deleted: + return Response({"detail": "Like deleted successfully."}, status=status.HTTP_204_NO_CONTENT) + + Like.objects.create(article=article, user=request.user) + return Response({"detail": "Like created successfully"}, status=status.HTTP_201_CREATED) + + + + +class UserDetailView(APIView): + """ + API view to retrieve a user's details along with their articles. + """ + def get(self, request): + user = User.objects.get(id=request.user.id) + serializer = UserSerializer(user, context={'request': request}) + return Response(serializer.data) \ No newline at end of file diff --git a/backend/db.sqlite3 b/backend/db.sqlite3 new file mode 100644 index 0000000..4666aff Binary files /dev/null and b/backend/db.sqlite3 differ diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..d47dbf9 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Inspire_Ink.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3af6709 Binary files /dev/null and b/backend/requirements.txt differ