From ebecba4fb1f76e6ca21cb820691b1359dcb44a58 Mon Sep 17 00:00:00 2001 From: Ahmed Nagi <144544047+mindfreq@users.noreply.github.com> Date: Sat, 25 Jan 2025 21:15:23 +0200 Subject: [PATCH] update README file and create requirements.txt --- backend/.gitignore | 278 +++++++++++++++++++++++++++++++ backend/Inspire_Ink/__init__.py | 0 backend/Inspire_Ink/asgi.py | 16 ++ backend/Inspire_Ink/settings.py | 171 +++++++++++++++++++ backend/Inspire_Ink/urls.py | 33 ++++ backend/Inspire_Ink/wsgi.py | 16 ++ backend/app/README.md | 0 backend/app/__init__.py | 0 backend/app/admin.py | 43 +++++ backend/app/apps.py | 6 + backend/app/models.py | 104 ++++++++++++ backend/app/serializers.py | 83 +++++++++ backend/app/tests/__init__.py | 0 backend/app/tests/test_models.py | 113 +++++++++++++ backend/app/urls.py | 13 ++ backend/app/views.py | 104 ++++++++++++ backend/db.sqlite3 | Bin 0 -> 241664 bytes backend/manage.py | 22 +++ backend/requirements.txt | Bin 0 -> 988 bytes 19 files changed, 1002 insertions(+) create mode 100644 backend/.gitignore create mode 100644 backend/Inspire_Ink/__init__.py create mode 100644 backend/Inspire_Ink/asgi.py create mode 100644 backend/Inspire_Ink/settings.py create mode 100644 backend/Inspire_Ink/urls.py create mode 100644 backend/Inspire_Ink/wsgi.py create mode 100644 backend/app/README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/admin.py create mode 100644 backend/app/apps.py create mode 100644 backend/app/models.py create mode 100644 backend/app/serializers.py create mode 100644 backend/app/tests/__init__.py create mode 100644 backend/app/tests/test_models.py create mode 100644 backend/app/urls.py create mode 100644 backend/app/views.py create mode 100644 backend/db.sqlite3 create mode 100644 backend/manage.py create mode 100644 backend/requirements.txt 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 0000000000000000000000000000000000000000..4666affd25bc8e7addb45885b82d5b637eff16b3 GIT binary patch literal 241664 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%0D2`Bhl8{wa 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3af6709dd3831856fe08bb77934806cf26acfe5e GIT binary patch literal 988 zcmZ`&?M{P05S-s8J_@AtL;d5!v;~BUJp`IsA6}i=?Ez|<%MrNUotd3IzQ4BU@q!v1 z3Y2)}S|P;~cZPf1VbAjp12al)#g|xcuPtL~&-FF49CI=|f1III*b=8F+JFQ{1b)5o ztRd>BaYSxMcJ2-rw5$l!*fG0B#S>+&O~HZ;3uDhs|H&9~d=R^_JY`HeavX?qBwDcO z#uTR5Trs9-!p^0!r`n3xTZlp5&Yt37#y!=^$DWEy)BDD+v*Em2oxIJ+meN6WIM;K# zG9hBkj#~Cqu!A%4r9wDzD>`r>lN|0Ws@XW7m}+tz*+q0YyH*9BorqW2Bz9w5)vmE+ zZNnAFtLltPT}0lN6WLQ+obSZG91$)v)qZC*7r$Yu7k{9>72OWRD)=tNQLSpcOS$w; z#qP3C!#nk-di|Rt%>P|FX2k88vnQu+WHM&!t>sF7O=|AFdvvSX?@(cVXi`2gY3$iW oGjmOt@vDek_V*r(=FW5){5@QJ%qKSno|!vFvP literal 0 HcmV?d00001