├── core_apps ├── __init__.py ├── common │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── admin.py │ ├── views.py │ ├── apps.py │ ├── models.py │ └── exceptions.py ├── search │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── admin.py │ ├── apps.py │ ├── urls.py │ ├── serializers.py │ ├── views.py │ └── search_indexes.py ├── users │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── factories.py │ │ └── test_models.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── views.py │ ├── apps.py │ ├── forms.py │ ├── models.py │ ├── admin.py │ ├── serializers.py │ └── managers.py ├── articles │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── pagination.py │ ├── exceptions.py │ ├── admin.py │ ├── apps.py │ ├── permissions.py │ ├── custom_tag_field.py │ ├── urls.py │ ├── filters.py │ ├── renderers.py │ ├── read_time_engine.py │ ├── models.py │ ├── views.py │ └── serializers.py ├── comments │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── admin.py │ ├── apps.py │ ├── urls.py │ ├── models.py │ ├── serializers.py │ └── views.py ├── favorites │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── admin.py │ ├── exceptions.py │ ├── serializers.py │ ├── apps.py │ ├── urls.py │ ├── models.py │ └── views.py ├── profiles │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_urls.py │ │ ├── test_models.py │ │ ├── factories.py │ │ └── test_views.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── pagination.py │ ├── exceptions.py │ ├── apps.py │ ├── admin.py │ ├── signals.py │ ├── urls.py │ ├── renderers.py │ ├── models.py │ ├── serializers.py │ └── views.py ├── ratings │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── urls.py │ ├── admin.py │ ├── apps.py │ ├── exceptions.py │ ├── serializers.py │ ├── models.py │ └── views.py └── reactions │ ├── __init__.py │ ├── migrations │ ├── __init__.py │ └── 0001_initial.py │ ├── admin.py │ ├── urls.py │ ├── apps.py │ ├── serializers.py │ ├── models.py │ └── views.py ├── whoosh_index ├── MAIN_WRITELOCK ├── _MAIN_1.toc └── MAIN_cg34i80lfmva03c8.seg ├── authors_api ├── settings │ ├── __init__.py │ ├── local.py │ ├── production.py │ └── base.py ├── __init__.py ├── celery.py ├── asgi.py ├── wsgi.py └── urls.py ├── .dockerignore ├── project.tar ├── requirements ├── production.txt ├── local.txt └── base.txt ├── pyproject.toml ├── docker ├── local │ ├── postgres │ │ ├── maintenance │ │ │ ├── _sourced │ │ │ │ ├── constants.sh │ │ │ │ ├── yes_no.sh │ │ │ │ ├── countdown.sh │ │ │ │ └── messages.sh │ │ │ ├── backups │ │ │ ├── backup │ │ │ └── restore │ │ └── Dockerfile │ ├── nginx │ │ ├── Dockerfile │ │ └── default.conf │ └── django │ │ ├── celery │ │ ├── worker │ │ │ └── start │ │ └── flower │ │ │ └── start │ │ ├── start │ │ ├── entrypoint │ │ └── Dockerfile ├── production │ └── django │ │ ├── celery │ │ ├── worker │ │ │ └── start │ │ └── flower │ │ │ └── start │ │ ├── start │ │ ├── entrypoint │ │ └── Dockerfile └── digital_ocean_server_deploy.sh ├── .envs ├── .local │ ├── .postgres │ └── .django └── .production │ ├── .postgres │ └── .django ├── pytest.ini ├── setup.cfg ├── manage.py ├── production.yml ├── conftest.py ├── proxy.yml ├── Makefile ├── local.yml └── .gitignore /core_apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/search/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/users/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /whoosh_index/MAIN_WRITELOCK: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /authors_api/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/articles/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/comments/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/favorites/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/profiles/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/ratings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/reactions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/users/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/profiles/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/users/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/articles/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/comments/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/common/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/favorites/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/profiles/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/ratings/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/reactions/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core_apps/search/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea 2 | env 3 | **/.git 4 | .gitignore 5 | .vscode 6 | README.md 7 | LICENSE -------------------------------------------------------------------------------- /core_apps/search/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /project.tar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/API-Imperfect/authors-haven-api-live/HEAD/project.tar -------------------------------------------------------------------------------- /authors_api/__init__.py: -------------------------------------------------------------------------------- 1 | from .celery import app as celery_app 2 | 3 | __all__ = ("celery_app",) 4 | -------------------------------------------------------------------------------- /core_apps/common/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /core_apps/common/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /core_apps/search/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /core_apps/users/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /requirements/production.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | gunicorn==20.1.0 4 | psycopg2==2.9.3 5 | whitenoise==5.3.0 -------------------------------------------------------------------------------- /whoosh_index/_MAIN_1.toc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/API-Imperfect/authors-haven-api-live/HEAD/whoosh_index/_MAIN_1.toc -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | extend-exclude = ''' 3 | / ( 4 | | env 5 | )/ 6 | ''' 7 | 8 | [tool.isort] 9 | profile = "black" -------------------------------------------------------------------------------- /core_apps/comments/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Comment 4 | 5 | admin.site.register(Comment) 6 | -------------------------------------------------------------------------------- /core_apps/favorites/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Favorite 4 | 5 | admin.site.register(Favorite) 6 | -------------------------------------------------------------------------------- /core_apps/reactions/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Reaction 4 | 5 | admin.site.register(Reaction) 6 | -------------------------------------------------------------------------------- /docker/local/postgres/maintenance/_sourced/constants.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | BACKUP_DIR_PATH='/backups' 4 | BACKUP_FILE_PREFIX='backup' -------------------------------------------------------------------------------- /whoosh_index/MAIN_cg34i80lfmva03c8.seg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/API-Imperfect/authors-haven-api-live/HEAD/whoosh_index/MAIN_cg34i80lfmva03c8.seg -------------------------------------------------------------------------------- /docker/local/nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.21.5-alpine 2 | RUN rm /etc/nginx/conf.d/default.conf 3 | COPY ./default.conf /etc/nginx/conf.d/default.conf -------------------------------------------------------------------------------- /.envs/.local/.postgres: -------------------------------------------------------------------------------- 1 | 2 | POSTGRES_HOST=postgres 3 | POSTGRES_PORT=5432 4 | POSTGRES_DB=authors-live 5 | POSTGRES_USER=alphaogilo 6 | POSTGRES_PASSWORD=admin123456 -------------------------------------------------------------------------------- /docker/production/django/celery/worker/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | 7 | exec celery -A authors_api worker -l INFO -------------------------------------------------------------------------------- /core_apps/articles/pagination.py: -------------------------------------------------------------------------------- 1 | from rest_framework.pagination import PageNumberPagination 2 | 3 | 4 | class ArticlePagination(PageNumberPagination): 5 | page_size = 5 6 | -------------------------------------------------------------------------------- /core_apps/profiles/pagination.py: -------------------------------------------------------------------------------- 1 | from rest_framework.pagination import PageNumberPagination 2 | 3 | 4 | class ProfilePagination(PageNumberPagination): 5 | page_size = 3 6 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = authors_api.settings.local 3 | python_files = tests.py test_*.py *_tests.py 4 | addopts = -p no:warnings --strict-markers --no-migrations --reuse-db -------------------------------------------------------------------------------- /docker/local/django/celery/worker/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | watchmedo auto-restart -d authors_api/ -p '*.py' -- celery -A authors_api worker --loglevel=info -------------------------------------------------------------------------------- /core_apps/profiles/tests/test_urls.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import resolve, reverse 3 | 4 | 5 | def test_all_profiles(): 6 | assert reverse("all-profiles") == "/api/v1/profiles/all/" 7 | -------------------------------------------------------------------------------- /core_apps/reactions/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path("/", views.ReactionAPIView.as_view(), name="user-reaction") 7 | ] 8 | -------------------------------------------------------------------------------- /core_apps/ratings/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import create_article_rating_view 4 | 5 | urlpatterns = [ 6 | path("/", create_article_rating_view, name="rate-article") 7 | ] 8 | -------------------------------------------------------------------------------- /core_apps/favorites/exceptions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.exceptions import APIException 2 | 3 | 4 | class AlreadyFavorited(APIException): 5 | status_code = 400 6 | default_detail = "You have already favorited this article" 7 | -------------------------------------------------------------------------------- /core_apps/profiles/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from core_apps.profiles.models import Profile 4 | 5 | 6 | def test_profile_str(profile): 7 | assert profile.__str__() == f"{profile.user.username}'s profile" 8 | -------------------------------------------------------------------------------- /docker/local/django/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | python3 manage.py migrate --no-input 8 | python3 manage.py collectstatic --no-input 9 | python3 manage.py runserver 0.0.0.0:8000 -------------------------------------------------------------------------------- /core_apps/articles/exceptions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.exceptions import APIException 2 | 3 | 4 | class UpdateArticle(APIException): 5 | status_code = 403 6 | default_detail = "You can't update an article that does not belong to you'" 7 | -------------------------------------------------------------------------------- /.envs/.production/.postgres: -------------------------------------------------------------------------------- 1 | 2 | POSTGRES_HOST=ec2-52-7-58-253.compute-1.amazonaws.com 3 | POSTGRES_PORT=5432 4 | POSTGRES_DB=da36mmm6jhqp0f 5 | POSTGRES_USER=meptkeuckeztnc 6 | POSTGRES_PASSWORD=90a735e3fb74073c4528f79bd4baf1e40e7d9465f98527c0de44a3ccfb0d958a -------------------------------------------------------------------------------- /core_apps/ratings/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Rating 4 | 5 | 6 | class RatingAdmin(admin.ModelAdmin): 7 | list_display = ["article", "rated_by", "value"] 8 | 9 | 10 | admin.site.register(Rating, RatingAdmin) 11 | -------------------------------------------------------------------------------- /docker/local/postgres/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:14.1 2 | 3 | COPY ./docker/local/postgres/maintenance /usr/local/bin/maintenance 4 | 5 | RUN chmod +x /usr/local/bin/maintenance/* 6 | RUN mv /usr/local/bin/maintenance/* /usr/local/bin \ 7 | && rmdir /usr/local/bin/maintenance -------------------------------------------------------------------------------- /core_apps/favorites/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import Favorite 4 | 5 | 6 | class FavoriteSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = Favorite 9 | fields = ["id", "user", "article"] 10 | -------------------------------------------------------------------------------- /core_apps/users/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class UsersConfig(AppConfig): 6 | default_auto_field = "django.db.models.BigAutoField" 7 | name = "core_apps.users" 8 | verbose_name = _("Users") 9 | -------------------------------------------------------------------------------- /docker/production/django/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | python3 /app/manage.py migrate 8 | python3 /app/manage.py collectstatic --no-input 9 | 10 | /usr/local/bin/gunicorn authors_api.wsgi --bind 0.0.0.0:1998 --chdir=/app -------------------------------------------------------------------------------- /core_apps/common/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class CommonConfig(AppConfig): 6 | default_auto_field = "django.db.models.BigAutoField" 7 | name = "core_apps.common" 8 | verbose_name = _("Common") 9 | -------------------------------------------------------------------------------- /core_apps/search/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class SearchConfig(AppConfig): 6 | default_auto_field = "django.db.models.BigAutoField" 7 | name = "core_apps.search" 8 | verbose_name = _("Search") 9 | -------------------------------------------------------------------------------- /requirements/local.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | psycopg2==2.9.3 4 | flake8==4.0.1 5 | black==21.12b0 6 | isort==5.9.3 7 | argh==0.26.2 8 | PyYAML==6.0 9 | watchdog==2.1.6 10 | 11 | pytest-django==4.5.2 12 | 13 | pytest-factoryboy==2.1.0 14 | 15 | Faker==11.3.0 16 | 17 | pytest-cov==3.0.0 -------------------------------------------------------------------------------- /.envs/.local/.django: -------------------------------------------------------------------------------- 1 | CELERY_BROKER=redis://redis:6379/0 2 | CELERY_BACKEND=redis://redis:6379/0 3 | 4 | DOMAIN=locahost:8080 5 | EMAIL_PORT=1025 6 | 7 | CELERY_FLOWER_USER=admin 8 | CELERY_FLOWER_PASSWORD=admin123456 9 | 10 | SIGNING_KEY=XSm8lLNS3Jl9zrOLGBN27QjnDPmaX05PRJtnEE9B7KUToG43AzE -------------------------------------------------------------------------------- /core_apps/ratings/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class RatingsConfig(AppConfig): 6 | default_auto_field = "django.db.models.BigAutoField" 7 | name = "core_apps.ratings" 8 | verbose_name = _("Ratings") 9 | -------------------------------------------------------------------------------- /core_apps/comments/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class CommentsConfig(AppConfig): 6 | default_auto_field = "django.db.models.BigAutoField" 7 | name = "core_apps.comments" 8 | verbose_name = _("Comments") 9 | -------------------------------------------------------------------------------- /core_apps/favorites/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class FavoritesConfig(AppConfig): 6 | default_auto_field = "django.db.models.BigAutoField" 7 | name = "core_apps.favorites" 8 | verbose_name = _("Favorites") 9 | -------------------------------------------------------------------------------- /core_apps/reactions/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class ReactionsConfig(AppConfig): 6 | default_auto_field = "django.db.models.BigAutoField" 7 | name = "core_apps.reactions" 8 | verbose_name = _("Reactions") 9 | -------------------------------------------------------------------------------- /core_apps/articles/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from . import models 4 | 5 | 6 | class ArticleAdmin(admin.ModelAdmin): 7 | list_display = ["pkid", "author", "slug", "article_read_time", "views"] 8 | list_display_links = ["pkid", "author"] 9 | 10 | 11 | admin.site.register(models.Article, ArticleAdmin) 12 | -------------------------------------------------------------------------------- /core_apps/articles/apps.py: -------------------------------------------------------------------------------- 1 | from tabnanny import verbose 2 | 3 | from django.apps import AppConfig 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | 7 | class ArticlesConfig(AppConfig): 8 | default_auto_field = "django.db.models.BigAutoField" 9 | name = "core_apps.articles" 10 | verbose_name = _("Articles") 11 | -------------------------------------------------------------------------------- /core_apps/favorites/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path( 7 | "articles/me/", 8 | views.ListUserFavoriteArticlesAPIView.as_view(), 9 | name="my-favorites", 10 | ), 11 | path("/", views.FavoriteAPIView.as_view(), name="favorite-article"), 12 | ] 13 | -------------------------------------------------------------------------------- /core_apps/ratings/exceptions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.exceptions import APIException 2 | 3 | 4 | class CantRateYourArticle(APIException): 5 | status_code = 403 6 | default_detail = "You can't rate/review your own article" 7 | 8 | 9 | class AlreadyRated(APIException): 10 | status_code = 400 11 | default_detail = "You have already rated this article" 12 | -------------------------------------------------------------------------------- /core_apps/profiles/exceptions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.exceptions import APIException 2 | 3 | 4 | class NotYourProfile(APIException): 5 | status_code = 403 6 | default_detail = "You can't edit a profile that doesn't belong to you!" 7 | 8 | 9 | class CantFollowYourself(APIException): 10 | status_code = 403 11 | default_detail = "You can't follow yourself" 12 | -------------------------------------------------------------------------------- /authors_api/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from celery import Celery 4 | from django.conf import settings 5 | 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authors_api.settings.production") 7 | 8 | app = Celery("authors_api") 9 | 10 | app.config_from_object("django.conf:settings", namespace="CELERY") 11 | 12 | app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) 13 | -------------------------------------------------------------------------------- /core_apps/profiles/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class ProfilesConfig(AppConfig): 6 | default_auto_field = "django.db.models.BigAutoField" 7 | name = "core_apps.profiles" 8 | verbose_name = _("Profiles") 9 | 10 | def ready(self): 11 | from core_apps.profiles import signals 12 | -------------------------------------------------------------------------------- /core_apps/search/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from rest_framework import routers 3 | 4 | from .views import SearchArticleView 5 | 6 | router = routers.DefaultRouter() 7 | router.register("search", SearchArticleView, basename="search-article") 8 | 9 | urlpatterns = [ 10 | path("search/", SearchArticleView.as_view({"get": "list"}), name="search-article") 11 | ] 12 | -------------------------------------------------------------------------------- /docker/local/postgres/maintenance/backups: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | working_dir="$(dirname ${0})" 8 | source "${working_dir}/_sourced/constants.sh" 9 | source "${working_dir}/_sourced/messages.sh" 10 | 11 | message_welcome "These are the backups you have as at this point in time:" 12 | 13 | ls -lht "${BACKUP_DIR_PATH}" -------------------------------------------------------------------------------- /core_apps/comments/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import CommentAPIView, CommentUpdateDeleteAPIView 4 | 5 | urlpatterns = [ 6 | path("/comment/", CommentAPIView.as_view(), name="comments"), 7 | path( 8 | "/comment//", 9 | CommentUpdateDeleteAPIView.as_view(), 10 | name="comment", 11 | ), 12 | ] 13 | -------------------------------------------------------------------------------- /core_apps/profiles/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Profile 4 | 5 | 6 | class ProfileAdmin(admin.ModelAdmin): 7 | list_display = ["pkid", "id", "user", "gender", "phone_number", "country", "city"] 8 | list_filter = ["gender", "country", "city"] 9 | list_display_links = ["id", "pkid"] 10 | 11 | 12 | admin.site.register(Profile, ProfileAdmin) 13 | -------------------------------------------------------------------------------- /docker/local/postgres/maintenance/_sourced/yes_no.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | yes_no() { 4 | declare desc="Prompt for confirmation. \$\"\{1\}\": confirmation message." 5 | local arg1="${1}" 6 | 7 | local response= 8 | read -r -p "${arg1} (y/[n])? " response 9 | if [[ "${response}" =~ ^[Yy]$ ]] 10 | then 11 | exit 0 12 | else 13 | exit 1 14 | fi 15 | } 16 | -------------------------------------------------------------------------------- /docker/local/postgres/maintenance/_sourced/countdown.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | countdown() { 4 | declare desc="A simple countdown. Source: https://superuser.com/a/611582" 5 | local seconds="${1}" 6 | local d=$(($(date +%s) + "${seconds}")) 7 | while [ "$d" -ge `date +%s` ]; do 8 | echo -ne "$(date -u --date @$(($d - `date +%s`)) +%H:%M:%S)\r"; 9 | sleep 0.1 10 | done 11 | } 12 | -------------------------------------------------------------------------------- /core_apps/articles/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions 2 | 3 | 4 | class IsOwnerOrReadOnly(permissions.BasePermission): 5 | message = ( 6 | "You are not allowed to update or delete an article that does not belong to you" 7 | ) 8 | 9 | def has_object_permission(self, request, view, obj): 10 | if request.method in permissions.SAFE_METHODS: 11 | return True 12 | 13 | return obj.author == request.user 14 | -------------------------------------------------------------------------------- /core_apps/search/serializers.py: -------------------------------------------------------------------------------- 1 | from drf_haystack.serializers import HaystackSerializer 2 | 3 | from core_apps.search.search_indexes import ArticleIndex 4 | 5 | 6 | class ArticleSearchSerializer(HaystackSerializer): 7 | class Meta: 8 | index_classes = [ArticleIndex] 9 | 10 | fields = ["author", "title", "body", "autocomplete", "created_at", "updated_at"] 11 | ignore_fields = ["autocomplete"] 12 | field_aliases = {"q": "autocomplete"} 13 | -------------------------------------------------------------------------------- /authors_api/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for authors_api project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authors_api.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /core_apps/articles/custom_tag_field.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import Tag 4 | 5 | 6 | class TagRelatedField(serializers.RelatedField): 7 | def get_queryset(self): 8 | return Tag.objects.all() 9 | 10 | def to_internal_value(self, data): 11 | tag, created = Tag.objects.get_or_create(tag=data, slug=data.lower()) 12 | 13 | return tag 14 | 15 | def to_representation(self, value): 16 | return value.tag 17 | -------------------------------------------------------------------------------- /authors_api/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for authors_api project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authors_api.settings.production") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /docker/local/django/celery/flower/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | worker_ready(){ 7 | celery -A authors_api inspect ping 8 | } 9 | 10 | until worker_ready; do 11 | >&2 echo "Celery workers are not available :-(" 12 | sleep 1 13 | done 14 | >&2 echo "Celery workers are available and ready!....:-)" 15 | 16 | celery -A authors_api \ 17 | --broker="${CELERY_BROKER}" \ 18 | flower \ 19 | --basic_auth="${CELERY_FLOWER_USER}:${CELERY_FLOWER_PASSWORD}" -------------------------------------------------------------------------------- /docker/production/django/celery/flower/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | 6 | worker_ready(){ 7 | celery -A authors_api inspect ping 8 | } 9 | 10 | until worker_ready; do 11 | >&2 echo "Celery workers are not available :-(" 12 | sleep 1 13 | done 14 | >&2 echo "Celery workers are available and ready!....:-)" 15 | 16 | celery -A authors_api \ 17 | --broker="${CELERY_BROKER}" \ 18 | flower \ 19 | --basic_auth="${CELERY_FLOWER_USER}:${CELERY_FLOWER_PASSWORD}" -------------------------------------------------------------------------------- /core_apps/reactions/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import Reaction 4 | 5 | 6 | class ReactionSerializer(serializers.ModelSerializer): 7 | created_at = serializers.SerializerMethodField() 8 | 9 | def get_created_at(self, obj): 10 | now = obj.created_at 11 | formatted_date = now.strftime("%m/%d/%Y, %H:%M:%S") 12 | return formatted_date 13 | 14 | class Meta: 15 | model = Reaction 16 | exclude = ["pkid", "updated_at"] 17 | -------------------------------------------------------------------------------- /core_apps/search/views.py: -------------------------------------------------------------------------------- 1 | from drf_haystack import viewsets 2 | from drf_haystack.filters import HaystackAutocompleteFilter 3 | from rest_framework import permissions 4 | 5 | from core_apps.articles.models import Article 6 | 7 | from .serializers import ArticleSearchSerializer 8 | 9 | 10 | class SearchArticleView(viewsets.HaystackViewSet): 11 | permission_classes = [permissions.AllowAny] 12 | index_models = [Article] 13 | serializer_class = ArticleSearchSerializer 14 | filter_backends = [HaystackAutocompleteFilter] 15 | -------------------------------------------------------------------------------- /core_apps/common/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import models 4 | 5 | # Create your models here. 6 | 7 | 8 | class TimeStampedUUIDModel(models.Model): 9 | pkid = models.BigAutoField(primary_key=True, editable=False) 10 | id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) 11 | created_at = models.DateTimeField(auto_now_add=True) 12 | updated_at = models.DateTimeField(auto_now=True) 13 | 14 | class Meta: 15 | abstract = True 16 | ordering = ["-created_at", "-updated_at"] 17 | -------------------------------------------------------------------------------- /core_apps/ratings/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import Rating 4 | 5 | 6 | class RatingSerializer(serializers.ModelSerializer): 7 | rated_by = serializers.SerializerMethodField(read_only=True) 8 | article = serializers.SerializerMethodField(read_only=True) 9 | 10 | class Meta: 11 | model = Rating 12 | fields = ["id", "article", "rated_by", "value"] 13 | 14 | def get_rated_by(self, obj): 15 | return obj.rated_by.username 16 | 17 | def get_article(self, obj): 18 | return obj.article.title 19 | -------------------------------------------------------------------------------- /core_apps/comments/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.db import models 3 | 4 | from core_apps.common.models import TimeStampedUUIDModel 5 | 6 | User = get_user_model() 7 | 8 | 9 | class Comment(TimeStampedUUIDModel): 10 | article = models.ForeignKey( 11 | "articles.Article", on_delete=models.CASCADE, related_name="comments" 12 | ) 13 | author = models.ForeignKey("profiles.Profile", on_delete=models.CASCADE) 14 | body = models.TextField() 15 | 16 | def __str__(self): 17 | return f"{self.author} commented on {self.article}" 18 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | django==3.2.11 2 | django-environ==0.8.1 3 | djangorestframework==3.12.4 4 | django-cors-headers==3.10.1 5 | django-filter==21.1 6 | django-autoslug==1.9.8 7 | django-countries==7.2.1 8 | django-phonenumber-field==5.2.0 9 | phonenumbers==8.12.33 10 | drf-yasg==1.20.0 11 | 12 | Pillow==9.0.0 13 | 14 | argon2-cffi==21.3.0 15 | 16 | pytz==2021.3 17 | 18 | redis==4.1.0 19 | celery==5.2.3 20 | flower==1.0.0 21 | django-celery-email==3.0.0 22 | 23 | djoser==2.1.0 24 | djangorestframework-simplejwt==4.8.0 25 | PyJWT==2.1.0 26 | 27 | django-haystack==3.1.1 28 | Whoosh==2.7.4 29 | drf-haystack==1.8.11 -------------------------------------------------------------------------------- /core_apps/users/forms.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import forms as admin_forms 2 | from django.contrib.auth import get_user_model 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | User = get_user_model() 6 | 7 | 8 | class UserChangeForm(admin_forms.UserChangeForm): 9 | class Meta(admin_forms.UserChangeForm.Meta): 10 | model = User 11 | 12 | 13 | class UserCreationForm(admin_forms.UserCreationForm): 14 | class Meta(admin_forms.UserCreationForm.Meta): 15 | model = User 16 | error_messages = { 17 | "username": {"unique": _("This username has already been taken.")} 18 | } 19 | -------------------------------------------------------------------------------- /authors_api/settings/local.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | from .base import env 3 | 4 | DEBUG = True 5 | 6 | 7 | # SECURITY WARNING: keep the secret key used in production secret! 8 | SECRET_KEY = env( 9 | "DJANGO_SECRET_KEY", 10 | default="VoQE5G62Qu1Sk8cmBMa8V8D4nYhWazjaEoH9p9wWGPF4Pv23A3M68Wtme2BpHSwt", 11 | ) 12 | 13 | ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1"] 14 | 15 | EMAIL_BACKEND = "djcelery_email.backends.CeleryEmailBackend" 16 | EMAIL_HOST = env("EMAIL_HOST", default="mailhog") 17 | EMAIL_PORT = env("EMAIL_PORT") 18 | DEFAULT_FROM_EMAIL = "info@authors-haven.com" 19 | DOMAIN = env("DOMAIN") 20 | SITE_NAME = "Authors Haven" 21 | -------------------------------------------------------------------------------- /docker/local/postgres/maintenance/_sourced/messages.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | message_newline() { 4 | echo 5 | } 6 | 7 | message_debug() { 8 | echo -e "DEBUG: ${@}" 9 | } 10 | 11 | message_welcome() 12 | { 13 | echo -e "\e[1m${@}\e[0m" 14 | } 15 | 16 | message_warning() 17 | { 18 | echo -e "\e[33mWARNING\e[0m: ${@}" 19 | } 20 | 21 | message_error() 22 | { 23 | echo -e "\e[31mERROR\e[0m: ${@}" 24 | } 25 | 26 | message_info() 27 | { 28 | echo -e "\e[37mINFO\e[0m: ${@}" 29 | } 30 | 31 | message_suggestion() 32 | { 33 | echo -e "\e[33mSUGGESTION\e[0m: ${@}" 34 | } 35 | 36 | message_success() 37 | { 38 | echo -e "\e[32mSUCCESS\e[0m: ${@}" 39 | } 40 | -------------------------------------------------------------------------------- /core_apps/articles/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import ( 4 | ArticleCreateAPIView, 5 | ArticleDeleteAPIView, 6 | ArticleDetailView, 7 | ArticleListAPIView, 8 | update_article_api_view, 9 | ) 10 | 11 | urlpatterns = [ 12 | path("all/", ArticleListAPIView.as_view(), name="all-articles"), 13 | path("create/", ArticleCreateAPIView.as_view(), name="create-article"), 14 | path("details//", ArticleDetailView.as_view(), name="article-detail"), 15 | path("delete//", ArticleDeleteAPIView.as_view(), name="delete-article"), 16 | path("update//", update_article_api_view, name="update-article"), 17 | ] 18 | -------------------------------------------------------------------------------- /core_apps/profiles/signals.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.db.models.signals import post_save 4 | from django.dispatch import receiver 5 | 6 | from authors_api.settings.base import AUTH_USER_MODEL 7 | from core_apps.profiles.models import Profile 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | @receiver(post_save, sender=AUTH_USER_MODEL) 13 | def create_user_profile(sender, instance, created, **kwargs): 14 | if created: 15 | Profile.objects.create(user=instance) 16 | 17 | 18 | @receiver(post_save, sender=AUTH_USER_MODEL) 19 | def save_user_profile(sender, instance, **kwargs): 20 | instance.profile.save() 21 | logger.info(f"{instance}'s profile created") 22 | -------------------------------------------------------------------------------- /docker/digital_ocean_server_deploy.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | if [ -z "$DIGITAL_OCEAN_IP_ADDRESS" ] 4 | then 5 | echo "DIGITAL_OCEAN_IP_ADDRESS not defined" 6 | exit 0 7 | fi 8 | 9 | git archive --format tar --output ./project.tar main 10 | 11 | echo "Uploading the project.....:-)...Be Patient!" 12 | rsync ./project.tar root@$DIGITAL_OCEAN_IP_ADDRESS:/tmp/project.tar 13 | echo "Upload complete....:-)" 14 | 15 | 16 | echo "Building the image......." 17 | ssh -o StrictHostKeyChecking=no root@$DIGITAL_OCEAN_IP_ADDRESS << 'ENDSSH' 18 | mkdir -p /app 19 | rm -rf /app/* && tar -xf /tmp/project.tar -C /app 20 | docker-compose -f /app/production.yml build 21 | 22 | ENDSSH 23 | echo "Build completed successfully.......:-)" -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | exclude = .tox,.git,*/migrations/*,*env*,*venv*,__pycache__,*/staticfiles/*,*/mediafiles/*,node_modules 4 | 5 | 6 | [isort] 7 | line_length = 88 8 | skip = env/ 9 | multi_line_output = 3 10 | skip_glob = **/migrations/*.py 11 | include_trailing_comma = true 12 | force_grid_wrap = 0 13 | use_parentheses = true 14 | 15 | [coverage:run] 16 | source = . 17 | omit= 18 | *apps.py, 19 | *settings.py, 20 | *urls.py, 21 | *wsgi.py, 22 | *asgi.py, 23 | manage.py, 24 | conftest.py, 25 | *base.py, 26 | *local.py, 27 | *production.py, 28 | *__init__.py, 29 | */migrations/*, 30 | *tests/*, 31 | */env/*, 32 | */venv/*, 33 | 34 | [coverage:report] 35 | show_missing = True -------------------------------------------------------------------------------- /.envs/.production/.django: -------------------------------------------------------------------------------- 1 | DJANGO_SETTINGS_MODULE=authors_api.settings.production 2 | DJANGO_SECRET_KEY=RZOLRPXO4CoPkvo1wyH0tbGcT6jTG0PaDzAvtUFX6lTEbILrxgPJ3Lnb3m6dZk0a 3 | 4 | DJANGO_ADMIN_URL=supersecret/ 5 | 6 | DJANGO_ALLOWED_HOSTS=178.62.46.189,www.apiimperfect.site,apiimperfect.site 7 | 8 | RABBITMQ_DEFAULT_USER=admin 9 | RABBITMQ_DEFAULT_PASS=admin123456 10 | 11 | CELERY_BROKER=amqp://admin:admin123456@rabbitmq:5672/ 12 | CELERY_BACKEND=redis://redis:6379/0 13 | 14 | DJANGO_SECURE_SSL_REDIRECT=False 15 | 16 | CELERY_FLOWER_USER=admin 17 | CELERY_FLOWER_PASSWORD=admin123456 18 | 19 | SMTP_MAILGUN_PASSWORD=b8ed9a31325a0b0265d92aee806ae2d9-c3d1d1eb-29fa3c09 20 | DOMAIN=www.apiimperfect.site 21 | 22 | SIGNING_KEY=XSm8lLNS3Jl9zrOLGBN27QjnDPmaX05PRJtnEE9B7KUToG43AzE -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authors_api.settings.local") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /core_apps/profiles/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import ( 4 | FollowUnfollowAPIView, 5 | ProfileDetailAPIView, 6 | ProfileListAPIView, 7 | UpdateProfileAPIView, 8 | get_my_followers, 9 | ) 10 | 11 | urlpatterns = [ 12 | path("all/", ProfileListAPIView.as_view(), name="all-profiles"), 13 | path( 14 | "user//", ProfileDetailAPIView.as_view(), name="profile-details" 15 | ), 16 | path( 17 | "update//", UpdateProfileAPIView.as_view(), name="profile-update" 18 | ), 19 | path("/followers/", get_my_followers, name="my-followers"), 20 | path( 21 | "/follow/", 22 | FollowUnfollowAPIView.as_view(), 23 | name="follow-unfollow", 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /docker/local/django/entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | export DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}" 8 | 9 | postgres_ready() { 10 | python << END 11 | import sys 12 | import psycopg2 13 | try: 14 | psycopg2.connect( 15 | dbname="${POSTGRES_DB}", 16 | user="${POSTGRES_USER}", 17 | password="${POSTGRES_PASSWORD}", 18 | host="${POSTGRES_HOST}", 19 | port="${POSTGRES_PORT}", 20 | ) 21 | except psycopg2.OperationalError: 22 | sys.exit(-1) 23 | sys.exit(0) 24 | END 25 | } 26 | until postgres_ready; do 27 | >&2 echo "Waiting for PostgreSQL to become available.....:-(" 28 | sleep 1 29 | done 30 | >&2 echo "PostgreSQL is ready!!!!.....:-)" 31 | 32 | exec "$@" -------------------------------------------------------------------------------- /core_apps/articles/filters.py: -------------------------------------------------------------------------------- 1 | import django_filters as filters 2 | 3 | from core_apps.articles.models import Article 4 | 5 | 6 | class ArticleFilter(filters.FilterSet): 7 | author = filters.CharFilter( 8 | field_name="author__first_name", lookup_expr="icontains" 9 | ) 10 | title = filters.CharFilter(field_name="title", lookup_expr="icontains") 11 | tags = filters.CharFilter( 12 | field_name="tags", method="get_article_tags", lookup_expr="iexact" 13 | ) 14 | created_at = filters.IsoDateTimeFilter(field_name="created_at") 15 | updated_at = filters.IsoDateTimeFilter(field_name="updated_at") 16 | 17 | class Meta: 18 | model = Article 19 | fields = ["author", "title", "tags", "created_at", "updated_at"] 20 | 21 | def get_article_tags(self, queryset, tags, value): 22 | tag_values = value.replace(" ", "").split(",") 23 | return queryset.filter(tags__tag__in=tag_values).distinct() 24 | -------------------------------------------------------------------------------- /docker/local/nginx/default.conf: -------------------------------------------------------------------------------- 1 | upstream api { 2 | server api:8000; 3 | } 4 | 5 | server { 6 | client_max_body_size 20M; 7 | listen 80; 8 | 9 | location /api/v1/ { 10 | proxy_pass http://api; 11 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 12 | proxy_set_header Host $host; 13 | proxy_redirect off; 14 | } 15 | 16 | location /supersecret { 17 | proxy_pass http://api; 18 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 19 | proxy_set_header Host $host; 20 | proxy_redirect off; 21 | } 22 | 23 | location /redoc { 24 | proxy_pass http://api; 25 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 26 | proxy_set_header Host $host; 27 | proxy_redirect off; 28 | } 29 | 30 | location /staticfiles/ { 31 | alias /app/staticfiles/; 32 | } 33 | 34 | location /mediafiles/ { 35 | alias /app/mediafiles/; 36 | } 37 | } -------------------------------------------------------------------------------- /core_apps/favorites/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.db import models 3 | 4 | from core_apps.articles.models import Article 5 | from core_apps.common.models import TimeStampedUUIDModel 6 | 7 | User = get_user_model() 8 | 9 | 10 | class Favorite(TimeStampedUUIDModel): 11 | user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="favorites") 12 | article = models.ForeignKey( 13 | Article, on_delete=models.CASCADE, related_name="article_favorites" 14 | ) 15 | 16 | def __str__(self): 17 | return f"{self.user.username} favorited {self.article.title}" 18 | 19 | def is_favorited(self, user, article): 20 | try: 21 | article = self.article 22 | user = self.user 23 | except Article.DoesNotExist: 24 | pass 25 | 26 | queryset = Favorite.objects.filter(article_id=article, user_id=user) 27 | 28 | if queryset: 29 | return True 30 | return False 31 | -------------------------------------------------------------------------------- /docker/local/postgres/maintenance/backup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | working_dir="$(dirname ${0})" 8 | source "${working_dir}/_sourced/constants.sh" 9 | source "${working_dir}/_sourced/messages.sh" 10 | 11 | message_welcome "Backing up the '${POSTGRES_DB}' database...." 12 | 13 | if [[ "${POSTGRES_USER}" == "postgres" ]]; then 14 | message_error "Backing up as 'postgres' user is not supported. Assign 'POSTGRES_USER' env with anthor one and try again" 15 | exit 1 16 | fi 17 | 18 | export PGHOST="${POSTGRES_HOST}" 19 | export PGPORT="${POSTGRES_PORT}" 20 | export PGUSER="${POSTGRES_USER}" 21 | export PGPASSWORD="${POSTGRES_PASSWORD}" 22 | export PGDATABASE="${POSTGRES_DB}" 23 | 24 | backup_filename="${BACKUP_FILE_PREFIX}_$(date +'%Y_%m_%dT%H_%M_%S').sql.gz" 25 | pg_dump | gzip > "${BACKUP_DIR_PATH}/${backup_filename}" 26 | 27 | message_success "'${POSTGRES_DB}' database backup '${backup_filename}' has been created and placed in '${BACKUP_DIR_PATH}'." -------------------------------------------------------------------------------- /core_apps/search/search_indexes.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from haystack import indexes 3 | 4 | from core_apps.articles.models import Article 5 | 6 | 7 | class ArticleIndex(indexes.SearchIndex, indexes.Indexable): 8 | text = indexes.CharField(document=True) 9 | author = indexes.CharField(model_attr="author") 10 | title = indexes.CharField(model_attr="title") 11 | body = indexes.CharField(model_attr="body") 12 | created_at = indexes.CharField(model_attr="created_at") 13 | updated_at = indexes.CharField(model_attr="updated_at") 14 | 15 | @staticmethod 16 | def prepare_author(obj): 17 | return "" if not obj.author else obj.author.username 18 | 19 | @staticmethod 20 | def prepare_autocomplete(obj): 21 | return " ".join((obj.author.username, obj.title, obj.description)) 22 | 23 | def get_model(self): 24 | return Article 25 | 26 | def index_queryset(self, using=None): 27 | return self.get_model().objects.filter(created_at__lte=timezone.now()) 28 | -------------------------------------------------------------------------------- /core_apps/articles/renderers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from rest_framework.renderers import JSONRenderer 4 | 5 | 6 | class ArticleJSONRenderer(JSONRenderer): 7 | charset = "utf-8" 8 | 9 | def render(self, data, accepted_media_type=None, renderer_context=None): 10 | status_code = renderer_context["response"].status_code 11 | errors = data.get("errors", None) 12 | 13 | if errors is not None: 14 | return super(ArticleJSONRenderer, self).render(data) 15 | return json.dumps({"status_code": status_code, "article": data}) 16 | 17 | 18 | class ArticlesJSONRenderer(JSONRenderer): 19 | charset = "utf-8" 20 | 21 | def render(self, data, accepted_media_type=None, renderer_context=None): 22 | status_code = renderer_context["response"].status_code 23 | errors = data.get("errors", None) 24 | 25 | if errors is not None: 26 | return super(ArticlesJSONRenderer, self).render(data) 27 | return json.dumps({"status_code": status_code, "articles": data}) 28 | -------------------------------------------------------------------------------- /core_apps/profiles/renderers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from rest_framework.renderers import JSONRenderer 4 | 5 | 6 | class ProfileJSONRenderer(JSONRenderer): 7 | charset = "utf-8" 8 | 9 | def render(self, data, accepted_media_type=None, renderer_context=None): 10 | status_code = renderer_context["response"].status_code 11 | errors = data.get("errors", None) 12 | 13 | if errors is not None: 14 | return super(ProfileJSONRenderer, self).render(data) 15 | return json.dumps({"status_code": status_code, "profile": data}) 16 | 17 | 18 | class ProfilesJSONRenderer(JSONRenderer): 19 | charset = "utf-8" 20 | 21 | def render(self, data, accepted_media_type=None, renderer_context=None): 22 | status_code = renderer_context["response"].status_code 23 | errors = data.get("errors", None) 24 | 25 | if errors is not None: 26 | return super(ProfilesJSONRenderer, self).render(data) 27 | return json.dumps({"status_code": status_code, "profiles": data}) 28 | -------------------------------------------------------------------------------- /docker/production/django/entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | export DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}" 8 | 9 | postgres_ready() { 10 | python << END 11 | import sys 12 | import psycopg2 13 | try: 14 | psycopg2.connect( 15 | dbname="${POSTGRES_DB}", 16 | user="${POSTGRES_USER}", 17 | password="${POSTGRES_PASSWORD}", 18 | host="${POSTGRES_HOST}", 19 | port="${POSTGRES_PORT}", 20 | ) 21 | except psycopg2.OperationalError: 22 | sys.exit(-1) 23 | sys.exit(0) 24 | END 25 | } 26 | until postgres_ready; do 27 | >&2 echo "Waiting for PostgreSQL to become available.....:-(" 28 | sleep 1 29 | done 30 | >&2 echo "PostgreSQL is ready!!!!.....:-)" 31 | 32 | rabbitmq_ready() { 33 | echo "Waiting for Rabbitmq to become available......:-(" 34 | 35 | while ! nc -z rabbitmq 5672; do 36 | sleep 1 37 | done 38 | 39 | echo "Rabbitmq started...:-)" 40 | } 41 | 42 | rabbitmq_ready 43 | 44 | exec "$@" -------------------------------------------------------------------------------- /core_apps/users/tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from django.contrib.auth import get_user_model 3 | from django.db.models.signals import post_save 4 | from faker import Factory as FakerFactory 5 | 6 | faker = FakerFactory.create() 7 | User = get_user_model() 8 | 9 | 10 | @factory.django.mute_signals(post_save) 11 | class UserFactory(factory.django.DjangoModelFactory): 12 | first_name = factory.LazyAttribute(lambda x: faker.first_name()) 13 | last_name = factory.LazyAttribute(lambda x: faker.last_name()) 14 | username = factory.LazyAttribute(lambda x: faker.first_name().lower()) 15 | email = factory.LazyAttribute(lambda o: "%s@example.org" % o.username) 16 | password = factory.LazyAttribute(lambda x: faker.password()) 17 | is_active = True 18 | is_staff = False 19 | 20 | class Meta: 21 | model = User 22 | 23 | @classmethod 24 | def _create(cls, models_class, *args, **kwargs): 25 | manager = cls._get_manager(models_class) 26 | if "is_superuser" in kwargs: 27 | return manager.create_superuser(*args, **kwargs) 28 | else: 29 | return manager.create_user(*args, **kwargs) 30 | -------------------------------------------------------------------------------- /production.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | api: &api 5 | build: 6 | context: . 7 | dockerfile: ./docker/production/django/Dockerfile 8 | command: /start 9 | image: authors-haven-api 10 | env_file: 11 | - ./.envs/.production/.django 12 | - ./.envs/.production/.postgres 13 | depends_on: 14 | - redis 15 | networks: 16 | - reverseproxy-nw 17 | 18 | redis: 19 | image: redis:6-alpine 20 | networks: 21 | - reverseproxy-nw 22 | 23 | rabbitmq: 24 | image: rabbitmq:3-management 25 | env_file: 26 | - ./.envs/.production/.django 27 | networks: 28 | - reverseproxy-nw 29 | 30 | celery_worker: 31 | <<: *api 32 | image: authors-haven-celeryworker 33 | command: /start-celeryworker 34 | networks: 35 | - reverseproxy-nw 36 | 37 | flower: 38 | <<: *api 39 | image: authors-haven-flower 40 | command: /start-flower 41 | networks: 42 | - reverseproxy-nw 43 | 44 | networks: 45 | reverseproxy-nw: 46 | external: true 47 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pytest_factoryboy import register 3 | 4 | from core_apps.profiles.tests.factories import ProfileFactory 5 | from core_apps.users.tests.factories import UserFactory 6 | 7 | register(UserFactory) 8 | register(ProfileFactory) 9 | 10 | 11 | @pytest.fixture 12 | def base_user(db, user_factory): 13 | new_user = user_factory.create() 14 | return new_user 15 | 16 | 17 | @pytest.fixture 18 | def super_user(db, user_factory): 19 | new_user = user_factory.create(is_staff=True, is_superuser=True) 20 | return new_user 21 | 22 | 23 | @pytest.fixture 24 | def profile(db, profile_factory): 25 | user_profile = profile_factory.create() 26 | return user_profile 27 | 28 | 29 | @pytest.fixture 30 | def test_user(db, user_factory): 31 | user = user_factory.create() 32 | yield user 33 | 34 | 35 | @pytest.fixture 36 | def test_user2(db, user_factory): 37 | user = user_factory.create() 38 | yield user 39 | 40 | 41 | @pytest.fixture 42 | def test_profile(db, test_user): 43 | profile = ProfileFactory(user=test_user) 44 | yield profile 45 | 46 | 47 | @pytest.fixture 48 | def test_profile2(db, test_user2): 49 | profile = ProfileFactory(user=test_user2) 50 | yield profile 51 | -------------------------------------------------------------------------------- /core_apps/ratings/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.db import models 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from core_apps.common.models import TimeStampedUUIDModel 6 | 7 | User = get_user_model() 8 | 9 | 10 | class Rating(TimeStampedUUIDModel): 11 | class Range(models.IntegerChoices): 12 | RATING_1 = 1, _("poor") 13 | RATING_2 = 2, _("fair") 14 | RATING_3 = 3, _("good") 15 | RATING_4 = 4, _("very good") 16 | RATING_5 = 5, _("excellent") 17 | 18 | article = models.ForeignKey( 19 | "articles.Article", related_name="article_ratings", on_delete=models.CASCADE 20 | ) 21 | rated_by = models.ForeignKey( 22 | User, related_name="user_who_rated", on_delete=models.CASCADE 23 | ) 24 | value = models.IntegerField( 25 | verbose_name=_("rating value"), 26 | choices=Range.choices, 27 | default=0, 28 | help_text="1=Poor, 2=Fair, 3=Good, 4=Very Good, 5=Excellent", 29 | ) 30 | review = models.TextField(verbose_name=_("rating review"), blank=True) 31 | 32 | class Meta: 33 | unique_together = ["rated_by", "article"] 34 | 35 | def __str__(self): 36 | return f"{self.article.title} rated at {self.value}" 37 | -------------------------------------------------------------------------------- /core_apps/common/exceptions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.views import exception_handler 2 | 3 | 4 | def common_exception_handler(exc, context): 5 | 6 | response = exception_handler(exc, context) 7 | 8 | handlers = { 9 | "NotFound": _handle_not_found_error, 10 | "ValidationError": _handle_generic_error, 11 | } 12 | 13 | exception_class = exc.__class__.__name__ 14 | 15 | if exception_class in handlers: 16 | return handlers[exception_class](exc, context, response) 17 | return response 18 | 19 | 20 | def _handle_generic_error(exc, context, response): 21 | status_code = response.status_code 22 | response.data = {"status_code": status_code, "errors": response.data} 23 | 24 | return response 25 | 26 | 27 | def _handle_not_found_error(exc, context, response): 28 | view = context.get("view", None) 29 | 30 | if view and hasattr(view, "queryset") and view.queryset is not None: 31 | status_code = response.status_code 32 | error_key = view.queryset.model._meta.verbose_name 33 | response.data = { 34 | "status_code": status_code, 35 | "errors": {error_key: response.data["detail"]}, 36 | } 37 | 38 | else: 39 | response = _handle_generic_error(exc, context, response) 40 | return response 41 | -------------------------------------------------------------------------------- /core_apps/profiles/tests/factories.py: -------------------------------------------------------------------------------- 1 | from unicodedata import category 2 | 3 | import factory 4 | from django.db.models.signals import post_save 5 | from faker import Factory as FakerFactory 6 | 7 | from core_apps.profiles.models import Profile 8 | from core_apps.users.tests.factories import UserFactory 9 | 10 | faker = FakerFactory.create() 11 | 12 | 13 | @factory.django.mute_signals(post_save) 14 | class ProfileFactory(factory.django.DjangoModelFactory): 15 | user = factory.SubFactory(UserFactory) 16 | phone_number = factory.LazyAttribute(lambda x: faker.phone_number()) 17 | about_me = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5)) 18 | gender = factory.LazyAttribute(lambda x: f"other") 19 | country = factory.LazyAttribute(lambda x: faker.country_code()) 20 | city = factory.LazyAttribute(lambda x: faker.city()) 21 | profile_photo = factory.LazyAttribute( 22 | lambda x: faker.file_extension(category="image") 23 | ) 24 | twitter_handle = factory.LazyAttribute(lambda x: f"@example") 25 | 26 | class Meta: 27 | model = Profile 28 | 29 | @factory.post_generation 30 | def follows(self, create, extracted, **kwargs): 31 | if not create: 32 | return 33 | if extracted: 34 | for follow in extracted: 35 | self.follows.add(follow) 36 | -------------------------------------------------------------------------------- /core_apps/comments/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-02-12 10:42 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import uuid 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ('articles', '0001_initial'), 14 | ('profiles', '0001_initial'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Comment', 20 | fields=[ 21 | ('pkid', models.BigAutoField(editable=False, primary_key=True, serialize=False)), 22 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), 23 | ('created_at', models.DateTimeField(auto_now_add=True)), 24 | ('updated_at', models.DateTimeField(auto_now=True)), 25 | ('body', models.TextField()), 26 | ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='articles.article')), 27 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='profiles.profile')), 28 | ], 29 | options={ 30 | 'ordering': ['-created_at', '-updated_at'], 31 | 'abstract': False, 32 | }, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /core_apps/ratings/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import generics, permissions, status 2 | from rest_framework.decorators import api_view, permission_classes 3 | from rest_framework.response import Response 4 | 5 | from core_apps.articles.models import Article 6 | 7 | from .exceptions import AlreadyRated, CantRateYourArticle 8 | from .models import Rating 9 | 10 | 11 | @api_view(["POST"]) 12 | @permission_classes([permissions.IsAuthenticated]) 13 | def create_article_rating_view(request, article_id): 14 | author = request.user 15 | article = Article.objects.get(id=article_id) 16 | data = request.data 17 | 18 | if article.author == author: 19 | raise CantRateYourArticle 20 | 21 | already_exists = article.article_ratings.filter(rated_by__pkid=author.pkid).exists() 22 | if already_exists: 23 | raise AlreadyRated 24 | elif data["value"] == 0: 25 | formatted_response = {"detail": "You can't give a zero rating"} 26 | return Response(formatted_response, status=status.HTTP_400_BAD_REQUEST) 27 | else: 28 | rating = Rating.objects.create( 29 | article=article, 30 | rated_by=request.user, 31 | value=data["value"], 32 | review=data["review"], 33 | ) 34 | 35 | return Response( 36 | {"success": "Rating has been added"}, status=status.HTTP_201_CREATED 37 | ) 38 | -------------------------------------------------------------------------------- /proxy.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | app: 4 | image: "jc21/nginx-proxy-manager:latest" 5 | restart: unless-stopped 6 | ports: 7 | - "80:80" 8 | - "443:443" 9 | - "81:81" 10 | - "5555:5555" 11 | - "15672:15672" 12 | environment: 13 | DB_MYSQL_HOST: "proxy-manager-db" 14 | DB_MYSQL_PORT: 3306 15 | DB_MYSQL_USER: "npm" 16 | DB_MYSQL_PASSWORD: "00000000000" 17 | DB_MYSQL_NAME: "npm" 18 | 19 | volumes: 20 | - /root/proxy-manager/data:/data:z 21 | - /root/proxy-manager/letsencrypt:/etc/letsencrypt 22 | depends_on: 23 | - proxy-manager-db 24 | networks: 25 | - nginx-proxy-manager-nw 26 | - reverseproxy-nw 27 | 28 | proxy-manager-db: 29 | image: "jc21/mariadb-aria:latest" 30 | restart: unless-stopped 31 | environment: 32 | MYSQL_ROOT_PASSWORD: "1111111111" 33 | MYSQL_DATABASE: "npm" 34 | MYSQL_USER: "npm" 35 | MYSQL_PASSWORD: "00000000000" 36 | volumes: 37 | - /root/proxy-manager/data/mysql:/var/lib/mysql:z 38 | networks: 39 | - nginx-proxy-manager-nw 40 | 41 | networks: 42 | nginx-proxy-manager-nw: 43 | reverseproxy-nw: 44 | external: true 45 | -------------------------------------------------------------------------------- /core_apps/favorites/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-02-12 10:42 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import uuid 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('articles', '0001_initial'), 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Favorite', 21 | fields=[ 22 | ('pkid', models.BigAutoField(editable=False, primary_key=True, serialize=False)), 23 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), 24 | ('created_at', models.DateTimeField(auto_now_add=True)), 25 | ('updated_at', models.DateTimeField(auto_now=True)), 26 | ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_favorites', to='articles.article')), 27 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to=settings.AUTH_USER_MODEL)), 28 | ], 29 | options={ 30 | 'ordering': ['-created_at', '-updated_at'], 31 | 'abstract': False, 32 | }, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /core_apps/reactions/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.db import models 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from core_apps.articles.models import Article 6 | from core_apps.common.models import TimeStampedUUIDModel 7 | 8 | User = get_user_model() 9 | 10 | 11 | class ReactionManager(models.Manager): 12 | def likes(self): 13 | return self.get_queryset().filter(reaction__gt=0).count() 14 | 15 | def dislikes(self): 16 | return self.get_queryset().filter(reaction__lt=0).count() 17 | 18 | def has_reacted(self): 19 | request = self.context.get("request") 20 | if request: 21 | self.get_queryset().filter(user=request) 22 | 23 | 24 | class Reaction(TimeStampedUUIDModel): 25 | class Reactions(models.IntegerChoices): 26 | LIKE = 1, _("like") 27 | DISLIKE = -1, _("dislike") 28 | 29 | user = models.ForeignKey(User, on_delete=models.CASCADE) 30 | article = models.ForeignKey( 31 | Article, on_delete=models.CASCADE, related_name="article_reactions" 32 | ) 33 | reaction = models.IntegerField( 34 | verbose_name=_("like-dislike"), choices=Reactions.choices 35 | ) 36 | 37 | objects = ReactionManager() 38 | 39 | class Meta: 40 | unique_together = ["user", "article", "reaction"] 41 | 42 | def __str__(self): 43 | return ( 44 | f"{self.user.username} voted on {self.article.title} with a {self.reaction}" 45 | ) 46 | -------------------------------------------------------------------------------- /authors_api/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.urls import include, path 4 | from drf_yasg import openapi 5 | from drf_yasg.views import get_schema_view 6 | from rest_framework import permissions 7 | 8 | schema_view = get_schema_view( 9 | openapi.Info( 10 | title="Authors API", 11 | default_version="v1", 12 | description="API endpoints for the Authors API course", 13 | contact=openapi.Contact(email="api.imperfect@gmail.com"), 14 | license=openapi.License(name="MIT License"), 15 | ), 16 | public=True, 17 | permission_classes=(permissions.AllowAny,), 18 | ) 19 | 20 | urlpatterns = [ 21 | path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), 22 | path(settings.ADMIN_URL, admin.site.urls), 23 | path("api/v1/auth/", include("djoser.urls")), 24 | path("api/v1/auth/", include("djoser.urls.jwt")), 25 | path("api/v1/profiles/", include("core_apps.profiles.urls")), 26 | path("api/v1/articles/", include("core_apps.articles.urls")), 27 | path("api/v1/ratings/", include("core_apps.ratings.urls")), 28 | path("api/v1/vote/", include("core_apps.reactions.urls")), 29 | path("api/v1/favorite/", include("core_apps.favorites.urls")), 30 | path("api/v1/comments/", include("core_apps.comments.urls")), 31 | path("api/v1/haystack/", include("core_apps.search.urls")), 32 | ] 33 | 34 | admin.site.site_header = "Authors Haven API Admin" 35 | admin.site.site_title = "Authors Haven API Admin Portal" 36 | admin.site.index_title = "Welcome to the Authors Haven API Portal" 37 | -------------------------------------------------------------------------------- /core_apps/users/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin 4 | from django.db import models 5 | from django.utils import timezone 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | from .managers import CustomUserManager 9 | 10 | 11 | class User(AbstractBaseUser, PermissionsMixin): 12 | pkid = models.BigAutoField(primary_key=True, editable=False) 13 | id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) 14 | username = models.CharField( 15 | verbose_name=_("username"), db_index=True, max_length=255, unique=True 16 | ) 17 | first_name = models.CharField(verbose_name=_("first name"), max_length=50) 18 | last_name = models.CharField(verbose_name=_("last name"), max_length=50) 19 | email = models.EmailField( 20 | verbose_name=_("email address"), db_index=True, unique=True 21 | ) 22 | is_staff = models.BooleanField(default=False) 23 | is_active = models.BooleanField(default=True) 24 | 25 | date_joined = models.DateTimeField(default=timezone.now) 26 | 27 | USERNAME_FIELD = "email" 28 | REQUIRED_FIELDS = ["username", "first_name", "last_name"] 29 | 30 | objects = CustomUserManager() 31 | 32 | class Meta: 33 | verbose_name = _("user") 34 | verbose_name_plural = _("users") 35 | 36 | def __str__(self): 37 | return self.username 38 | 39 | @property 40 | def get_full_name(self): 41 | return f"{self.first_name.title()} {self.last_name.title()}" 42 | 43 | def get_short_name(self): 44 | return self.first_name 45 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | docker compose -f local.yml up --build -d --remove-orphans 3 | 4 | up: 5 | docker compose -f local.yml up -d 6 | 7 | down: 8 | docker compose -f local.yml down 9 | 10 | show_logs: 11 | docker compose -f local.yml logs 12 | 13 | migrate: 14 | docker compose -f local.yml run --rm api python3 manage.py migrate 15 | 16 | makemigrations: 17 | docker compose -f local.yml run --rm api python3 manage.py makemigrations 18 | 19 | collectstatic: 20 | docker compose -f local.yml run --rm api python3 manage.py collectstatic --no-input --clear 21 | 22 | superuser: 23 | docker compose -f local.yml run --rm api python3 manage.py createsuperuser 24 | 25 | down-v: 26 | docker compose -f local.yml down -v 27 | 28 | volume: 29 | docker volume inspect authors-src_local_postgres_data 30 | 31 | authors-db: 32 | docker compose -f local.yml exec postgres psql --username=alphaogilo --dbname=authors-live 33 | 34 | flake8: 35 | docker compose -f local.yml exec api flake8 . 36 | 37 | black-check: 38 | docker compose -f local.yml exec api black --check --exclude=migrations . 39 | 40 | black-diff: 41 | docker compose -f local.yml exec api black --diff --exclude=migrations . 42 | 43 | black: 44 | docker compose -f local.yml exec api black --exclude=migrations . 45 | 46 | isort-check: 47 | docker compose -f local.yml exec api isort . --check-only --skip env --skip migrations 48 | 49 | isort-diff: 50 | docker compose -f local.yml exec api isort . --diff --skip env --skip migrations 51 | 52 | isort: 53 | docker compose -f local.yml exec api isort . --skip env --skip migrations 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /docker/local/postgres/maintenance/restore: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | working_dir="$(dirname ${0})" 8 | source "${working_dir}/_sourced/constants.sh" 9 | source "${working_dir}/_sourced/messages.sh" 10 | 11 | 12 | if [[ -z ${1+x} ]]; then 13 | message_error "Backup filename is not specified yet it is a required parameter. Make sure you provide one and try again." 14 | exit 1 15 | fi 16 | backup_filename="${BACKUP_DIR_PATH}/${1}" 17 | if [[ ! -f "${backup_filename}" ]]; then 18 | message_error "No backup with the specified filename found. Check out the 'backups' maintenance script output to see if there is one and try again." 19 | exit 1 20 | fi 21 | 22 | message_welcome "Restoring the '${POSTGRES_DB}' database from the '${backup_filename}' backup..." 23 | 24 | if [[ "${POSTGRES_USER}" == "postgres" ]]; then 25 | message_error "Restoring as 'postgres' user is not supported. Assign 'POSTGRES_USER' env with another one and try again." 26 | exit 1 27 | fi 28 | 29 | export PGHOST="${POSTGRES_HOST}" 30 | export PGPORT="${POSTGRES_PORT}" 31 | export PGUSER="${POSTGRES_USER}" 32 | export PGPASSWORD="${POSTGRES_PASSWORD}" 33 | export PGDATABASE="${POSTGRES_DB}" 34 | 35 | message_info "Dropping the database..." 36 | dropdb "${PGDATABASE}" 37 | 38 | message_info "Creating a new database..." 39 | createdb --owner="${POSTGRES_USER}" 40 | 41 | message_info "Applying the backup to the new database..." 42 | gunzip -c "${backup_filename}" | psql "${POSTGRES_DB}" 43 | 44 | message_success "The '${POSTGRES_DB}' database has been restored from the '${backup_filename}' backup." -------------------------------------------------------------------------------- /core_apps/reactions/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-02-12 10:42 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django.db.models.manager 7 | import uuid 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ('articles', '0001_initial'), 16 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='Reaction', 22 | fields=[ 23 | ('pkid', models.BigAutoField(editable=False, primary_key=True, serialize=False)), 24 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), 25 | ('created_at', models.DateTimeField(auto_now_add=True)), 26 | ('updated_at', models.DateTimeField(auto_now=True)), 27 | ('reaction', models.IntegerField(choices=[(1, 'like'), (-1, 'dislike')], verbose_name='like-dislike')), 28 | ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_reactions', to='articles.article')), 29 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 30 | ], 31 | options={ 32 | 'unique_together': {('user', 'article', 'reaction')}, 33 | }, 34 | managers=[ 35 | ('object', django.db.models.manager.Manager()), 36 | ], 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /core_apps/comments/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from rest_framework import serializers 3 | 4 | from .models import Comment 5 | 6 | User = get_user_model() 7 | 8 | 9 | class CommentSerializer(serializers.ModelSerializer): 10 | created_at = serializers.SerializerMethodField() 11 | updated_at = serializers.SerializerMethodField() 12 | 13 | def get_created_at(self, obj): 14 | now = obj.created_at 15 | formatted_date = now.strftime("%m/%d/%Y, %H:%M:%S") 16 | return formatted_date 17 | 18 | def get_updated_at(self, obj): 19 | then = obj.updated_at 20 | formatted_date = then.strftime("%m/%d/%Y, %H:%M:%S") 21 | return formatted_date 22 | 23 | class Meta: 24 | model = Comment 25 | fields = ["id", "author", "article", "body", "created_at", "updated_at"] 26 | 27 | 28 | class CommentListSerializer(serializers.ModelSerializer): 29 | author = serializers.ReadOnlyField(source="author.user.username") 30 | article = serializers.ReadOnlyField(source="article.title") 31 | created_at = serializers.SerializerMethodField() 32 | updated_at = serializers.SerializerMethodField() 33 | 34 | def get_created_at(self, obj): 35 | now = obj.created_at 36 | formatted_date = now.strftime("%m/%d/%Y, %H:%M:%S") 37 | return formatted_date 38 | 39 | def get_updated_at(self, obj): 40 | then = obj.updated_at 41 | formatted_date = then.strftime("%m/%d/%Y, %H:%M:%S") 42 | return formatted_date 43 | 44 | class Meta: 45 | model = Comment 46 | fields = ["id", "author", "article", "body", "created_at", "updated_at"] 47 | -------------------------------------------------------------------------------- /docker/local/django/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON_VERSION=3.9-slim-bullseye 2 | 3 | FROM python:${PYTHON_VERSION} as python 4 | 5 | FROM python as python-build-stage 6 | ARG BUILD_ENVIRONMENT=local 7 | 8 | RUN apt-get update && apt-get install --no-install-recommends -y \ 9 | build-essential \ 10 | libpq-dev 11 | 12 | COPY ./requirements . 13 | 14 | RUN pip wheel --wheel-dir /usr/src/app/wheels \ 15 | -r ${BUILD_ENVIRONMENT}.txt 16 | 17 | 18 | FROM python as python-run-stage 19 | 20 | ARG BUILD_ENVIRONMENT=local 21 | ARG APP_HOME=/app 22 | 23 | ENV PYTHONDONTWRITEBYTECODE 1 24 | 25 | ENV PYTHONUNBUFFERED 1 26 | 27 | ENV BUILD_ENV ${BUILD_ENVIRONMENT} 28 | 29 | WORKDIR ${APP_HOME} 30 | 31 | RUN apt-get update && apt-get install --no-install-recommends -y \ 32 | libpq-dev \ 33 | gettext \ 34 | && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ 35 | && rm -rf /var/lib/apt/lists/* 36 | 37 | 38 | COPY --from=python-build-stage /usr/src/app/wheels /wheels/ 39 | 40 | RUN pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* \ 41 | && rm -rf /wheels/ 42 | 43 | COPY ./docker/local/django/entrypoint /entrypoint 44 | RUN sed -i 's/\r$//g' /entrypoint 45 | RUN chmod +x /entrypoint 46 | 47 | COPY ./docker/local/django/start /start 48 | RUN sed -i 's/\r$//g' /start 49 | RUN chmod +x /start 50 | 51 | COPY ./docker/local/django/celery/worker/start /start-celeryworker 52 | RUN sed -i 's/\r$//g' /start-celeryworker 53 | RUN chmod +x /start-celeryworker 54 | 55 | COPY ./docker/local/django/celery/flower/start /start-flower 56 | RUN sed -i 's/\r$//g' /start-flower 57 | RUN chmod +x /start-flower 58 | 59 | COPY . ${APP_HOME} 60 | 61 | ENTRYPOINT ["/entrypoint"] -------------------------------------------------------------------------------- /core_apps/ratings/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-02-12 10:42 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import uuid 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('articles', '0001_initial'), 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Rating', 21 | fields=[ 22 | ('pkid', models.BigAutoField(editable=False, primary_key=True, serialize=False)), 23 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), 24 | ('created_at', models.DateTimeField(auto_now_add=True)), 25 | ('updated_at', models.DateTimeField(auto_now=True)), 26 | ('value', models.IntegerField(choices=[(1, 'poor'), (2, 'fair'), (3, 'good'), (4, 'very good'), (5, 'excellent')], default=0, help_text='1=Poor, 2=Fair, 3=Good, 4=Very Good, 5=Excellent', verbose_name='rating value')), 27 | ('review', models.TextField(blank=True, verbose_name='rating review')), 28 | ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_ratings', to='articles.article')), 29 | ('rated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_who_rated', to=settings.AUTH_USER_MODEL)), 30 | ], 31 | options={ 32 | 'unique_together': {('rated_by', 'article')}, 33 | }, 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /core_apps/articles/read_time_engine.py: -------------------------------------------------------------------------------- 1 | class ArticleReadTimeEngine: 2 | def __init__(self, article): 3 | self.article = article 4 | 5 | self.words_per_minute = 250 6 | 7 | self.banner_image_adjustment_time = round(1 / 6, 3) 8 | 9 | def check_article_has_banner_image(self): 10 | has_banner_image = True 11 | if not self.article.banner_image: 12 | has_banner_image = False 13 | self.banner_image_adjustment_time = 0 14 | return has_banner_image 15 | 16 | def get_title(self): 17 | return self.article.title 18 | 19 | def get_tags(self): 20 | tag_words = [] 21 | [tag_words.extend(tag_word.split()) for tag_word in self.article.list_of_tags] 22 | return tag_words 23 | 24 | def get_body(self): 25 | return self.article.body 26 | 27 | def get_description(self): 28 | return self.article.description 29 | 30 | def get_article_details(self): 31 | details = [] 32 | details.extend(self.get_title().split()) 33 | details.extend(self.get_body().split()) 34 | details.extend(self.get_description().split()) 35 | details.extend(self.get_tags()) 36 | return details 37 | 38 | def get_read_time(self): 39 | word_length = len(self.get_article_details()) 40 | read_time = 0 41 | self.check_article_has_banner_image() 42 | 43 | if word_length: 44 | time_to_read = word_length / self.words_per_minute 45 | if time_to_read < 1: 46 | read_time = ( 47 | str(round((time_to_read + self.banner_image_adjustment_time) * 60)) 48 | + " second(s)" 49 | ) 50 | else: 51 | read_time = ( 52 | str(round(time_to_read + self.banner_image_adjustment_time)) 53 | + " minute(s)" 54 | ) 55 | return read_time 56 | -------------------------------------------------------------------------------- /docker/production/django/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON_VERSION=3.9-slim-bullseye 2 | 3 | FROM python:${PYTHON_VERSION} as python 4 | 5 | FROM python as python-build-stage 6 | ARG BUILD_ENVIRONMENT=production 7 | 8 | RUN apt-get update && apt-get install --no-install-recommends -y \ 9 | build-essential \ 10 | libpq-dev 11 | 12 | COPY ./requirements . 13 | 14 | RUN pip wheel --wheel-dir /usr/src/app/wheels \ 15 | -r ${BUILD_ENVIRONMENT}.txt 16 | 17 | 18 | FROM python as python-run-stage 19 | 20 | ARG BUILD_ENVIRONMENT=production 21 | ARG APP_HOME=/app 22 | 23 | ENV PYTHONDONTWRITEBYTECODE 1 24 | 25 | ENV PYTHONUNBUFFERED 1 26 | 27 | ENV BUILD_ENV ${BUILD_ENVIRONMENT} 28 | 29 | WORKDIR ${APP_HOME} 30 | 31 | RUN addgroup --system django \ 32 | && adduser --system --ingroup django django 33 | 34 | RUN apt-get update && apt-get install --no-install-recommends -y \ 35 | libpq-dev \ 36 | gettext netcat \ 37 | && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ 38 | && rm -rf /var/lib/apt/lists/* 39 | 40 | 41 | COPY --from=python-build-stage /usr/src/app/wheels /wheels/ 42 | 43 | RUN pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* \ 44 | && rm -rf /wheels/ 45 | 46 | COPY --chown=django:django ./docker/production/django/entrypoint /entrypoint 47 | RUN sed -i 's/\r$//g' /entrypoint 48 | RUN chmod +x /entrypoint 49 | 50 | COPY --chown=django:django ./docker/production/django/start /start 51 | RUN sed -i 's/\r$//g' /start 52 | RUN chmod +x /start 53 | 54 | COPY --chown=django:django ./docker/production/django/celery/worker/start /start-celeryworker 55 | RUN sed -i 's/\r$//g' /start-celeryworker 56 | RUN chmod +x /start-celeryworker 57 | 58 | COPY ./docker/local/django/celery/flower/start /start-flower 59 | RUN sed -i 's/\r$//g' /start-flower 60 | RUN chmod +x /start-flower 61 | 62 | COPY --chown=django:django . ${APP_HOME} 63 | 64 | RUN chown django:django ${APP_HOME} 65 | 66 | USER django 67 | 68 | ENTRYPOINT ["/entrypoint"] -------------------------------------------------------------------------------- /core_apps/users/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin as BaseUserAdmin 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from .forms import UserChangeForm, UserCreationForm 6 | from .models import User 7 | 8 | 9 | class UserAdmin(BaseUserAdmin): 10 | ordering = ["email"] 11 | add_form = UserCreationForm 12 | form = UserChangeForm 13 | model = User 14 | list_display = [ 15 | "pkid", 16 | "id", 17 | "email", 18 | "username", 19 | "first_name", 20 | "last_name", 21 | "is_staff", 22 | "is_active", 23 | ] 24 | list_display_links = ["id", "email"] 25 | list_filter = ["email", "username", "first_name", "last_name", "is_staff"] 26 | fieldsets = ( 27 | ( 28 | _("Login Credentials"), 29 | { 30 | "fields": ( 31 | "email", 32 | "password", 33 | ) 34 | }, 35 | ), 36 | ( 37 | _("Personal Information"), 38 | {"fields": ("username", "first_name", "last_name")}, 39 | ), 40 | ( 41 | _("Permissions and Groups"), 42 | { 43 | "fields": ( 44 | "is_active", 45 | "is_staff", 46 | "is_superuser", 47 | "groups", 48 | "user_permissions", 49 | ) 50 | }, 51 | ), 52 | (_("Important Dates"), {"fields": ("last_login", "date_joined")}), 53 | ) 54 | add_fieldsets = ( 55 | ( 56 | None, 57 | { 58 | "classes": ("wide",), 59 | "fields": ("email", "password1", "password2", "is_staff", "is_active"), 60 | }, 61 | ), 62 | ) 63 | search_fields = ["email", "username", "first_name", "last_name"] 64 | 65 | 66 | admin.site.register(User, UserAdmin) 67 | -------------------------------------------------------------------------------- /core_apps/profiles/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.test import RequestFactory 3 | from rest_framework.exceptions import NotFound 4 | 5 | from core_apps.profiles.views import ( 6 | FollowUnfollowAPIView, 7 | ProfileDetailAPIView, 8 | ProfileListAPIView, 9 | ) 10 | from core_apps.users.models import User 11 | 12 | 13 | def test_get_profile_queryset(profile, rf: RequestFactory): 14 | view = ProfileListAPIView() 15 | request = rf.get("/fake-url/") 16 | request.user = profile.user 17 | 18 | view.request = request 19 | 20 | assert profile in view.queryset 21 | 22 | 23 | def test_get_user_profile_wrong_username(profile, rf: RequestFactory): 24 | view = ProfileDetailAPIView() 25 | request = rf.get("/fake-url/") 26 | request.user = profile.user 27 | 28 | view.request = request 29 | 30 | with pytest.raises(NotFound) as err: 31 | view.retrieve(request, username="notcorrectusername") 32 | assert str(err.value) == "A profile with this username does not exist" 33 | 34 | 35 | def test_get_profile_detail(profile, rf: RequestFactory): 36 | view = ProfileDetailAPIView() 37 | request = rf.get("/fake-url/") 38 | request.user = profile.user 39 | 40 | view.request = request 41 | 42 | response = view.retrieve(request, profile.user.username) 43 | 44 | assert response.status_code == 200 45 | assert response.data["username"] == profile.user.username 46 | assert response.data["first_name"] == profile.user.first_name 47 | assert response.data["email"] == profile.user.email 48 | assert response.data["about_me"] == profile.about_me 49 | 50 | 51 | def test_follow_user(test_profile, test_profile2, rf: RequestFactory): 52 | view = FollowUnfollowAPIView() 53 | request = rf.get("/fake-url/") 54 | request.user = test_profile.user 55 | 56 | view.request = request 57 | response = view.post(request, test_profile2.user.username) 58 | 59 | assert response.status_code == 200 60 | assert response.data["detail"] == f"You now follow {test_profile2.user.username}" 61 | -------------------------------------------------------------------------------- /core_apps/favorites/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import generics, permissions, status 2 | from rest_framework.response import Response 3 | from rest_framework.views import APIView 4 | 5 | from core_apps.articles.models import Article 6 | from core_apps.articles.serializers import ArticleCreateSerializer 7 | 8 | from .exceptions import AlreadyFavorited 9 | from .models import Favorite 10 | from .serializers import FavoriteSerializer 11 | 12 | 13 | class FavoriteAPIView(generics.CreateAPIView): 14 | permission_classes = [permissions.IsAuthenticated] 15 | serializer_class = FavoriteSerializer 16 | 17 | def post(self, request, slug): 18 | data = request.data 19 | article = Article.objects.get(slug=slug) 20 | user = request.user 21 | 22 | favorite = Favorite.objects.filter(user=user, article=article.pkid).first() 23 | 24 | if favorite: 25 | raise AlreadyFavorited 26 | else: 27 | data["article"] = article.pkid 28 | data["user"] = user.pkid 29 | serializer = self.get_serializer(data=data) 30 | serializer.is_valid(raise_exception=True) 31 | self.perform_create(serializer) 32 | data = serializer.data 33 | data["message"] = "Article added to favorites." 34 | return Response(data, status=status.HTTP_201_CREATED) 35 | 36 | 37 | class ListUserFavoriteArticlesAPIView(APIView): 38 | permission_classes = [permissions.IsAuthenticated] 39 | 40 | def get(self, request): 41 | Favorites = Favorite.objects.filter(user_id=request.user.pkid) 42 | 43 | favorite_articles = [] 44 | for favorite in Favorites: 45 | article = Article.objects.get(pkid=favorite.article.pkid) 46 | article = ArticleCreateSerializer( 47 | article, context={"article": article.slug, "request": request} 48 | ).data 49 | favorite_articles.append(article) 50 | favorites = {"my_favorites": favorite_articles} 51 | return Response(data=favorites, status=status.HTTP_200_OK) 52 | -------------------------------------------------------------------------------- /core_apps/users/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django_countries.serializer_fields import CountryField 3 | from djoser.serializers import UserCreateSerializer 4 | from phonenumber_field.serializerfields import PhoneNumberField 5 | from rest_framework import serializers 6 | 7 | User = get_user_model() 8 | 9 | 10 | class UserSerializer(serializers.ModelSerializer): 11 | gender = serializers.CharField(source="profile.gender") 12 | phone_number = PhoneNumberField(source="profile.phone_number") 13 | profile_photo = serializers.ReadOnlyField(source="profile.profile_photo") 14 | country = CountryField(source="profile.country") 15 | city = serializers.CharField(source="profile.city") 16 | first_name = serializers.SerializerMethodField() 17 | last_name = serializers.SerializerMethodField() 18 | full_name = serializers.SerializerMethodField() 19 | 20 | class Meta: 21 | model = User 22 | fields = [ 23 | "id", 24 | "username", 25 | "email", 26 | "first_name", 27 | "last_name", 28 | "full_name", 29 | "gender", 30 | "phone_number", 31 | "profile_photo", 32 | "country", 33 | "city", 34 | ] 35 | 36 | def get_first_name(self, obj): 37 | return obj.first_name.title() 38 | 39 | def get_last_name(self, obj): 40 | return obj.last_name.title() 41 | 42 | def get_full_name(self, obj): 43 | first_name = obj.user.first_name.title() 44 | last_name = obj.user.last_name.title() 45 | return f"{first_name} {last_name}" 46 | 47 | def to_representation(self, instance): 48 | representation = super(UserSerializer, self).to_representation(instance) 49 | if instance.is_superuser: 50 | representation["admin"] = True 51 | return representation 52 | 53 | 54 | class CreateUserSerializer(UserCreateSerializer): 55 | class Meta(UserCreateSerializer.Meta): 56 | model = User 57 | fields = ["id", "username", "email", "first_name", "last_name", "password"] 58 | -------------------------------------------------------------------------------- /core_apps/reactions/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions, status 2 | from rest_framework.exceptions import NotFound 3 | from rest_framework.response import Response 4 | from rest_framework.views import APIView 5 | 6 | from core_apps.articles.models import Article 7 | 8 | from .models import Reaction 9 | from .serializers import ReactionSerializer 10 | 11 | 12 | def find_article_helper(slug): 13 | try: 14 | article = Article.objects.get(slug=slug) 15 | except Article.DoesNotExist: 16 | raise NotFound(f"Article with the slug {slug} does not exist.") 17 | return article 18 | 19 | 20 | class ReactionAPIView(APIView): 21 | permission_classes = [permissions.IsAuthenticated] 22 | serializer_class = ReactionSerializer 23 | 24 | def set_reaction(self, article, user, reaction): 25 | try: 26 | existing_reaction = Reaction.objects.get(article=article, user=user) 27 | existing_reaction.delete() 28 | except Reaction.DoesNotExist: 29 | pass 30 | 31 | data = {"article": article.pkid, "user": user.pkid, "reaction": reaction} 32 | serializer = self.serializer_class(data=data) 33 | serializer.is_valid(raise_exception=True) 34 | serializer.save() 35 | 36 | response = {"message": "Reaction successfully set"} 37 | status_code = status.HTTP_201_CREATED 38 | return response, status_code 39 | 40 | def post(self, request, *args, **kwargs): 41 | slug = self.kwargs.get("slug") 42 | article = find_article_helper(slug) 43 | user = request.user 44 | reaction = request.data.get("reaction") 45 | 46 | try: 47 | existing_same_reaction = Reaction.objects.get( 48 | article=article, user=user, reaction=reaction 49 | ) 50 | existing_same_reaction.delete() 51 | response = { 52 | "message": f"You no-longer {'LIKE' if reaction in [1,'1'] else 'DISLIKE'}" 53 | } 54 | status_code = status.HTTP_200_OK 55 | except Reaction.DoesNotExist: 56 | response, status_code = self.set_reaction(article, user, reaction) 57 | 58 | return Response(response, status_code) 59 | -------------------------------------------------------------------------------- /core_apps/profiles/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-02-09 13:38 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django_countries.fields 7 | import phonenumber_field.modelfields 8 | import uuid 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [ 16 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='Profile', 22 | fields=[ 23 | ('pkid', models.BigAutoField(editable=False, primary_key=True, serialize=False)), 24 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), 25 | ('created_at', models.DateTimeField(auto_now_add=True)), 26 | ('updated_at', models.DateTimeField(auto_now=True)), 27 | ('phone_number', phonenumber_field.modelfields.PhoneNumberField(default='+250784123456', max_length=30, region=None, verbose_name='phone number')), 28 | ('about_me', models.TextField(default='say something about yourself', verbose_name='about me')), 29 | ('gender', models.CharField(choices=[('male', 'male'), ('female', 'female'), ('other', 'other')], default='other', max_length=20, verbose_name='gender')), 30 | ('country', django_countries.fields.CountryField(default='KE', max_length=2, verbose_name='country')), 31 | ('city', models.CharField(default='Nairobi', max_length=180, verbose_name='city')), 32 | ('profile_photo', models.ImageField(default='/profile_default.png', upload_to='', verbose_name='profile photo')), 33 | ('twitter_handle', models.CharField(blank=True, max_length=20, verbose_name='twitter_handle')), 34 | ('follows', models.ManyToManyField(blank=True, related_name='followed_by', to='profiles.Profile')), 35 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), 36 | ], 37 | options={ 38 | 'ordering': ['-created_at', '-updated_at'], 39 | 'abstract': False, 40 | }, 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /core_apps/users/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-02-07 08:03 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | import uuid 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ('auth', '0012_alter_user_first_name_max_length'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='User', 19 | fields=[ 20 | ('password', models.CharField(max_length=128, verbose_name='password')), 21 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 22 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 23 | ('pkid', models.BigAutoField(editable=False, primary_key=True, serialize=False)), 24 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), 25 | ('username', models.CharField(db_index=True, max_length=255, unique=True, verbose_name='username')), 26 | ('first_name', models.CharField(max_length=50, verbose_name='first name')), 27 | ('last_name', models.CharField(max_length=50, verbose_name='last name')), 28 | ('email', models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='email address')), 29 | ('is_staff', models.BooleanField(default=False)), 30 | ('is_active', models.BooleanField(default=True)), 31 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now)), 32 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), 33 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), 34 | ], 35 | options={ 36 | 'verbose_name': 'user', 37 | 'verbose_name_plural': 'users', 38 | }, 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /core_apps/profiles/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.db import models 3 | from django.utils.translation import gettext_lazy as _ 4 | from django_countries.fields import CountryField 5 | from phonenumber_field.modelfields import PhoneNumberField 6 | 7 | from core_apps.common.models import TimeStampedUUIDModel 8 | 9 | User = get_user_model() 10 | 11 | 12 | class Profile(TimeStampedUUIDModel): 13 | class Gender(models.TextChoices): 14 | MALE = "male", _("male") 15 | FEMALE = "female", _("female") 16 | OTHER = "other", _("other") 17 | 18 | user = models.OneToOneField(User, related_name="profile", on_delete=models.CASCADE) 19 | phone_number = PhoneNumberField( 20 | verbose_name=_("phone number"), max_length=30, default="+250784123456" 21 | ) 22 | about_me = models.TextField( 23 | verbose_name=_("about me"), 24 | default="say something about yourself", 25 | ) 26 | gender = models.CharField( 27 | verbose_name=_("gender"), 28 | choices=Gender.choices, 29 | default=Gender.OTHER, 30 | max_length=20, 31 | ) 32 | country = CountryField( 33 | verbose_name=_("country"), default="KE", blank=False, null=False 34 | ) 35 | city = models.CharField( 36 | verbose_name=_("city"), 37 | max_length=180, 38 | default="Nairobi", 39 | blank=False, 40 | null=False, 41 | ) 42 | profile_photo = models.ImageField( 43 | verbose_name=_("profile photo"), default="/profile_default.png" 44 | ) 45 | twitter_handle = models.CharField( 46 | verbose_name=_("twitter_handle"), max_length=20, blank=True 47 | ) 48 | follows = models.ManyToManyField( 49 | "self", symmetrical=False, related_name="followed_by", blank=True 50 | ) 51 | 52 | def __str__(self): 53 | return f"{self.user.username}'s profile" 54 | 55 | def following_list(self): 56 | return self.follows.all() 57 | 58 | def followers_list(self): 59 | return self.followed_by.all() 60 | 61 | def follow(self, profile): 62 | self.follows.add(profile) 63 | 64 | def unfollow(self, profile): 65 | self.follows.remove(profile) 66 | 67 | def check_following(self, profile): 68 | return self.follows.filter(pkid=profile.pkid).exists() 69 | 70 | def check_is_followed_by(self, profile): 71 | return self.followed_by.filter(pkid=profile.pkid).exists() 72 | -------------------------------------------------------------------------------- /core_apps/users/managers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.base_user import BaseUserManager 2 | from django.core.exceptions import ValidationError 3 | from django.core.validators import validate_email 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | 7 | class CustomUserManager(BaseUserManager): 8 | def email_validator(self, email): 9 | try: 10 | validate_email(email) 11 | except ValidationError: 12 | raise ValueError(_("You must provide a valid email address")) 13 | 14 | def create_user( 15 | self, username, first_name, last_name, email, password, **extra_fields 16 | ): 17 | if not username: 18 | raise ValueError(_("Users must submit a username")) 19 | 20 | if not first_name: 21 | raise ValueError(_("Users must submit a first name")) 22 | 23 | if not last_name: 24 | raise ValueError(_("Users must submit a last name")) 25 | 26 | if email: 27 | email = self.normalize_email(email) 28 | self.email_validator(email) 29 | else: 30 | raise ValueError(_("Base User Account: An email address is required")) 31 | 32 | user = self.model( 33 | username=username, 34 | first_name=first_name, 35 | last_name=last_name, 36 | email=email, 37 | **extra_fields 38 | ) 39 | 40 | user.set_password(password) 41 | extra_fields.setdefault("is_staff", False) 42 | extra_fields.setdefault("is_superuser", False) 43 | user.save(using=self._db) 44 | return user 45 | 46 | def create_superuser( 47 | self, username, first_name, last_name, email, password, **extra_fields 48 | ): 49 | 50 | extra_fields.setdefault("is_staff", True) 51 | extra_fields.setdefault("is_superuser", True) 52 | extra_fields.setdefault("is_active", True) 53 | 54 | if extra_fields.get("is_staff") is not True: 55 | raise ValueError(_("Superusers must have is_staff=True")) 56 | 57 | if extra_fields.get("is_superuser") is not True: 58 | raise ValueError(_("Superusers must have is_superuser=True")) 59 | 60 | if not password: 61 | raise ValueError(_("Superusers must have a password")) 62 | 63 | if email: 64 | email = self.normalize_email(email) 65 | self.email_validator(email) 66 | else: 67 | raise ValueError(_("Admin Account: An email address is required")) 68 | 69 | user = self.create_user( 70 | username, first_name, last_name, email, password, **extra_fields 71 | ) 72 | user.save(using=self._db) 73 | return user 74 | -------------------------------------------------------------------------------- /core_apps/articles/models.py: -------------------------------------------------------------------------------- 1 | from autoslug import AutoSlugField 2 | from django.contrib.auth import get_user_model 3 | from django.db import models 4 | from django.db.models import Avg 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from core_apps.common.models import TimeStampedUUIDModel 8 | from core_apps.ratings.models import Rating 9 | 10 | from .read_time_engine import ArticleReadTimeEngine 11 | 12 | User = get_user_model() 13 | 14 | 15 | class Tag(TimeStampedUUIDModel): 16 | tag = models.CharField(max_length=80) 17 | slug = models.SlugField(db_index=True, unique=True) 18 | 19 | class Meta: 20 | ordering = ["tag"] 21 | 22 | def __str__(self): 23 | return self.tag 24 | 25 | 26 | class Article(TimeStampedUUIDModel): 27 | author = models.ForeignKey( 28 | User, on_delete=models.CASCADE, verbose_name=_("user"), related_name="articles" 29 | ) 30 | title = models.CharField(verbose_name=_("title"), max_length=250) 31 | slug = AutoSlugField(populate_from="title", always_update=True, unique=True) 32 | description = models.CharField(verbose_name=_("description"), max_length=255) 33 | body = models.TextField(verbose_name=_("article content")) 34 | banner_image = models.ImageField( 35 | verbose_name=_("banner image"), default="/house_sample.jpg" 36 | ) 37 | tags = models.ManyToManyField(Tag, related_name="articles") 38 | views = models.IntegerField(verbose_name=_("article views"), default=0) 39 | 40 | def __str__(self): 41 | return f"{self.author.username}'s article" 42 | 43 | @property 44 | def list_of_tags(self): 45 | tags = [tag.tag for tag in self.tags.all()] 46 | return tags 47 | 48 | @property 49 | def article_read_time(self): 50 | time_to_read = ArticleReadTimeEngine(self) 51 | return time_to_read.get_read_time() 52 | 53 | def get_average_rating(self): 54 | if Rating.objects.all().count() > 0: 55 | rating = ( 56 | Rating.objects.filter(article=self.pkid).all().aggregate(Avg("value")) 57 | ) 58 | return round(rating["value__avg"], 1) if rating["value__avg"] else 0 59 | return 0 60 | 61 | 62 | class ArticleViews(TimeStampedUUIDModel): 63 | ip = models.CharField(verbose_name=_("ip address"), max_length=250) 64 | article = models.ForeignKey( 65 | Article, related_name="article_views", on_delete=models.CASCADE 66 | ) 67 | 68 | def __str__(self): 69 | return ( 70 | f"Total views on - {self.article.title} is - {self.article.views} view(s)" 71 | ) 72 | 73 | class Meta: 74 | verbose_name = "Total views on Article" 75 | verbose_name_plural = "Total Article Views" 76 | -------------------------------------------------------------------------------- /authors_api/settings/production.py: -------------------------------------------------------------------------------- 1 | from .base import * 2 | from .base import env 3 | 4 | SECRET_KEY = env("DJANGO_SECRET_KEY") 5 | 6 | ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["example.com"]) 7 | ADMIN_URL = env("DJANGO_ADMIN_URL") 8 | 9 | DATABASES = {"default": env.db("DATABASE_URL")} 10 | DATABASES["default"]["ATOMIC_REQUESTS"] = True 11 | 12 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 13 | 14 | SECURE_SSL_REDIRECT = True 15 | 16 | SESSION_COOKIE_SECURE = True 17 | CSRF_COOKIE_SECURE = True 18 | 19 | SECURE_HSTS_SECONDS = 60 20 | 21 | SECURE_HSTS_PRELOAD = env.bool("DJANGO_SECURE_HSTS_PRELOAD", default=True) 22 | 23 | SECURE_CONTENT_TYPE_NOSNIFF = env.bool( 24 | "DJANGO_SECURE_CONTENT_TYPE_NOSNIFF", default=True 25 | ) 26 | 27 | SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool( 28 | "DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS", default=True 29 | ) 30 | 31 | STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" 32 | 33 | DEFAULT_FROM_EMAIL = env( 34 | "DJANGO_DEFAULT_FROM_EMAIL", 35 | default="Authors Haven Support ", 36 | ) 37 | 38 | SITE_NAME = "Authors Haven" 39 | 40 | SERVER_EMAIL = env("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL) 41 | 42 | EMAIL_SUBJECT_PREFIX = env( 43 | "DJANGO_EMAIL_SUBJECT_PREFIX", 44 | default="[Authors Haven]", 45 | ) 46 | 47 | EMAIL_BACKEND = "djcelery_email.backends.CeleryEmailBackend" 48 | EMAIL_HOST = "smtp.mailgun.org" 49 | EMAIL_HOST_USER = "postmaster@mg.apiimperfect.site" 50 | EMAIL_HOST_PASSWORD = env("SMTP_MAILGUN_PASSWORD") 51 | EMAIL_PORT = 587 52 | EMAIL_USE_TLS = True 53 | DOMAIN = env("DOMAIN") 54 | 55 | 56 | # LOGGING 57 | LOGGING = { 58 | "version": 1, 59 | "disable_existing_loggers": False, 60 | "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, 61 | "formatters": { 62 | "verbose": { 63 | "format": "%(levelname)s %(asctime)s %(module)s " 64 | "%(process)d %(thread)d %(message)s" 65 | } 66 | }, 67 | "handlers": { 68 | "mail_admins": { 69 | "level": "ERROR", 70 | "filters": ["require_debug_false"], 71 | "class": "django.utils.log.AdminEmailHandler", 72 | }, 73 | "console": { 74 | "level": "DEBUG", 75 | "class": "logging.StreamHandler", 76 | "formatter": "verbose", 77 | }, 78 | }, 79 | "root": {"level": "INFO", "handlers": ["console"]}, 80 | "loggers": { 81 | "django.request": { 82 | "handlers": ["mail_admins"], 83 | "level": "ERROR", 84 | "propagate": True, 85 | }, 86 | "django.security.DisallowedHost": { 87 | "level": "ERROR", 88 | "handlers": ["console", "mail_admins"], 89 | "propagate": True, 90 | }, 91 | }, 92 | } 93 | -------------------------------------------------------------------------------- /core_apps/users/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from email.mime import base 2 | 3 | import pytest 4 | 5 | 6 | def test_user_str(base_user): 7 | assert base_user.__str__() == f"{base_user.username}" 8 | 9 | 10 | def test_user_short_name(base_user): 11 | short_name = f"{base_user.first_name}" 12 | assert base_user.get_short_name() == short_name 13 | 14 | 15 | def test_user_full_name(base_user): 16 | full_name = f"{base_user.first_name} {base_user.last_name}" 17 | assert base_user.get_full_name == full_name 18 | 19 | 20 | def test_base_user_email_is_normalized(base_user): 21 | email = base_user.email 22 | assert email == email.lower() 23 | 24 | 25 | def test_super_user_email_is_normalized(super_user): 26 | email = super_user.email 27 | assert email == email.lower() 28 | 29 | 30 | def test_super_user_is_not_staff(user_factory): 31 | with pytest.raises(ValueError) as err: 32 | user_factory.create(is_superuser=True, is_staff=False) 33 | assert str(err.value) == "Superusers must have is_staff=True" 34 | 35 | 36 | def test_super_user_is_not_superuser(user_factory): 37 | with pytest.raises(ValueError) as err: 38 | user_factory.create(is_superuser=False, is_staff=True) 39 | assert str(err.value) == "Superusers must have is_superuser=True" 40 | 41 | 42 | def test_create_user_with_no_email(user_factory): 43 | with pytest.raises(ValueError) as err: 44 | user_factory.create(email=None) 45 | assert str(err.value) == "Base User Account: An email address is required" 46 | 47 | 48 | def test_create_user_with_no_username(user_factory): 49 | with pytest.raises(ValueError) as err: 50 | user_factory.create(username=None) 51 | assert str(err.value) == "Users must submit a username" 52 | 53 | 54 | def test_create_user_with_no_firstname(user_factory): 55 | with pytest.raises(ValueError) as err: 56 | user_factory.create(first_name=None) 57 | assert str(err.value) == "Users must submit a first name" 58 | 59 | 60 | def test_create_user_with_no_lastname(user_factory): 61 | with pytest.raises(ValueError) as err: 62 | user_factory.create(last_name=None) 63 | assert str(err.value) == "Users must submit a last name" 64 | 65 | 66 | def test_create_superuser_with_no_email(user_factory): 67 | with pytest.raises(ValueError) as err: 68 | user_factory.create(email=None, is_superuser=True, is_staff=True) 69 | assert str(err.value) == "Admin Account: An email address is required" 70 | 71 | 72 | def test_create_superuser_with_no_password(user_factory): 73 | with pytest.raises(ValueError) as err: 74 | user_factory.create(password=None, is_superuser=True, is_staff=True) 75 | assert str(err.value) == "Superusers must have a password" 76 | 77 | 78 | def test_user_email_incorrect(user_factory): 79 | with pytest.raises(ValueError) as err: 80 | user_factory.create(email="authorshaven.com") 81 | assert str(err.value) == "You must provide a valid email address" 82 | -------------------------------------------------------------------------------- /core_apps/profiles/serializers.py: -------------------------------------------------------------------------------- 1 | from django_countries.serializer_fields import CountryField 2 | from rest_framework import serializers 3 | 4 | from .models import Profile 5 | 6 | 7 | class ProfileSerializer(serializers.ModelSerializer): 8 | username = serializers.CharField(source="user.username") 9 | first_name = serializers.CharField(source="user.first_name") 10 | last_name = serializers.CharField(source="user.last_name") 11 | email = serializers.EmailField(source="user.email") 12 | full_name = serializers.SerializerMethodField(read_only=True) 13 | profile_photo = serializers.SerializerMethodField() 14 | country = CountryField(name_only=True) 15 | following = serializers.SerializerMethodField() 16 | 17 | class Meta: 18 | model = Profile 19 | fields = [ 20 | "username", 21 | "first_name", 22 | "last_name", 23 | "full_name", 24 | "email", 25 | "id", 26 | "profile_photo", 27 | "phone_number", 28 | "about_me", 29 | "gender", 30 | "country", 31 | "city", 32 | "twitter_handle", 33 | "following", 34 | ] 35 | 36 | def get_full_name(self, obj): 37 | first_name = obj.user.first_name.title() 38 | last_name = obj.user.last_name.title() 39 | return f"{first_name} {last_name}" 40 | 41 | def get_profile_photo(self, obj): 42 | return obj.profile_photo.url 43 | 44 | def get_following(self, instance): 45 | request = self.context.get("request", None) 46 | if request is None: 47 | return None 48 | if request.user.is_anonymous: 49 | return False 50 | 51 | current_user_profile = request.user.profile 52 | followee = instance 53 | following_status = current_user_profile.check_following(followee) 54 | return following_status 55 | 56 | 57 | class UpdateProfileSerializer(serializers.ModelSerializer): 58 | country = CountryField(name_only=True) 59 | 60 | class Meta: 61 | model = Profile 62 | fields = [ 63 | "phone_number", 64 | "profile_photo", 65 | "about_me", 66 | "gender", 67 | "country", 68 | "city", 69 | "twitter_handle", 70 | ] 71 | 72 | 73 | class FollowingSerializer(serializers.ModelSerializer): 74 | username = serializers.CharField(source="user.username", read_only=True) 75 | first_name = serializers.CharField(source="user.first_name", read_only=True) 76 | last_name = serializers.CharField(source="user.last_name", read_only=True) 77 | following = serializers.BooleanField(default=True) 78 | 79 | class Meta: 80 | model = Profile 81 | fields = [ 82 | "username", 83 | "first_name", 84 | "last_name", 85 | "profile_photo", 86 | "about_me", 87 | "twitter_handle", 88 | "following", 89 | ] 90 | -------------------------------------------------------------------------------- /core_apps/comments/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import generics, permissions, status 2 | from rest_framework.exceptions import NotFound 3 | from rest_framework.response import Response 4 | 5 | from core_apps.articles.models import Article 6 | 7 | from .models import Comment 8 | from .serializers import CommentListSerializer, CommentSerializer 9 | 10 | 11 | class CommentAPIView(generics.GenericAPIView): 12 | permission_classes = [permissions.IsAuthenticated] 13 | serializer_class = CommentSerializer 14 | 15 | def post(self, request, **kwargs): 16 | try: 17 | slug = self.kwargs.get("slug") 18 | article = Article.objects.get(slug=slug) 19 | except Article.DoesNotExist: 20 | raise NotFound("That article does not exist in our catalog") 21 | 22 | comment = request.data 23 | author = request.user 24 | comment["author"] = author.pkid 25 | comment["article"] = article.pkid 26 | serializer = self.serializer_class(data=comment) 27 | serializer.is_valid(raise_exception=True) 28 | serializer.save() 29 | return Response(serializer.data, status=status.HTTP_201_CREATED) 30 | 31 | def get(self, request, **kwargs): 32 | try: 33 | slug = self.kwargs.get("slug") 34 | article = Article.objects.get(slug=slug) 35 | except Article.DoesNotExist: 36 | raise NotFound("That article does not exist in our catalog") 37 | 38 | try: 39 | comments = Comment.objects.filter(article_id=article.pkid) 40 | except Comment.DoesNotExist: 41 | raise NotFound("No comments found") 42 | 43 | serializer = CommentListSerializer( 44 | comments, many=True, context={"request": request} 45 | ) 46 | return Response( 47 | {"num_comments": len(serializer.data), "comments": serializer.data}, 48 | status=status.HTTP_200_OK, 49 | ) 50 | 51 | 52 | class CommentUpdateDeleteAPIView(generics.GenericAPIView): 53 | permission_classes = [permissions.IsAuthenticated] 54 | serializer_class = CommentSerializer 55 | 56 | def put(self, request, slug, id): 57 | try: 58 | comment_to_update = Comment.objects.get(id=id) 59 | except Comment.DoesNotExist: 60 | raise NotFound("Comment does not exist") 61 | 62 | data = request.data 63 | serializer = self.serializer_class(comment_to_update, data=data, partial=True) 64 | serializer.is_valid(raise_exception=True) 65 | serializer.save() 66 | response = { 67 | "message": "Comment updated successfully", 68 | "comment": serializer.data, 69 | } 70 | return Response(response, status=status.HTTP_200_OK) 71 | 72 | def delete(self, request, slug, id): 73 | try: 74 | comment_to_delete = Comment.objects.get(id=id) 75 | except Comment.DoesNotExist: 76 | raise NotFound("Comment does not exist") 77 | 78 | comment_to_delete.delete() 79 | response = {"message": "Comment deleted successfully!"} 80 | return Response(response, status=status.HTTP_200_OK) 81 | -------------------------------------------------------------------------------- /local.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | api: 5 | build: 6 | context: . 7 | dockerfile: ./docker/local/django/Dockerfile 8 | command: /start 9 | container_name: django-api 10 | volumes: 11 | - .:/app 12 | - static_volume:/app/staticfiles 13 | - media_volume:/app/mediafiles 14 | expose: 15 | - "8000" 16 | env_file: 17 | - ./.envs/.local/.django 18 | - ./.envs/.local/.postgres 19 | depends_on: 20 | - postgres 21 | - mailhog 22 | - redis 23 | networks: 24 | - authors-api-live 25 | 26 | postgres: 27 | build: 28 | context: . 29 | dockerfile: ./docker/local/postgres/Dockerfile 30 | container_name: postgres 31 | volumes: 32 | - local_postgres_data:/var/lib/postgresql/data 33 | - local_postgres_data_backups:/backups 34 | env_file: 35 | - ./.envs/.local/.postgres 36 | networks: 37 | - authors-api-live 38 | 39 | mailhog: 40 | image: mailhog/mailhog:v1.0.0 41 | container_name: mailhog 42 | ports: 43 | - "8025:8025" 44 | networks: 45 | - authors-api-live 46 | 47 | redis: 48 | image: redis:6-alpine 49 | container_name: redis 50 | networks: 51 | - authors-api-live 52 | 53 | celery_worker: 54 | build: 55 | context: . 56 | dockerfile: ./docker/local/django/Dockerfile 57 | command: /start-celeryworker 58 | container_name: celery_worker 59 | volumes: 60 | - .:/app 61 | env_file: 62 | - ./.envs/.local/.django 63 | - ./.envs/.local/.postgres 64 | depends_on: 65 | - redis 66 | - postgres 67 | - mailhog 68 | networks: 69 | - authors-api-live 70 | 71 | flower: 72 | build: 73 | context: . 74 | dockerfile: ./docker/local/django/Dockerfile 75 | command: /start-flower 76 | container_name: flower 77 | volumes: 78 | - .:/app 79 | env_file: 80 | - ./.envs/.local/.django 81 | - ./.envs/.local/.postgres 82 | ports: 83 | - "5555:5555" 84 | depends_on: 85 | - redis 86 | - postgres 87 | networks: 88 | - authors-api-live 89 | 90 | nginx: 91 | restart: always 92 | depends_on: 93 | - api 94 | volumes: 95 | - static_volume:/app/staticfiles 96 | - media_volume:/app/mediafiles 97 | build: 98 | context: ./docker/local/nginx 99 | dockerfile: Dockerfile 100 | ports: 101 | - "8080:80" 102 | networks: 103 | - authors-api-live 104 | 105 | networks: 106 | authors-api-live: 107 | driver: bridge 108 | 109 | volumes: 110 | local_postgres_data: {} 111 | local_postgres_data_backups: {} 112 | static_volume: 113 | media_volume: 114 | -------------------------------------------------------------------------------- /core_apps/articles/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.11 on 2022-02-12 10:42 2 | 3 | import autoslug.fields 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import uuid 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Article', 21 | fields=[ 22 | ('pkid', models.BigAutoField(editable=False, primary_key=True, serialize=False)), 23 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), 24 | ('created_at', models.DateTimeField(auto_now_add=True)), 25 | ('updated_at', models.DateTimeField(auto_now=True)), 26 | ('title', models.CharField(max_length=250, verbose_name='title')), 27 | ('slug', autoslug.fields.AutoSlugField(always_update=True, editable=False, populate_from='title', unique=True)), 28 | ('description', models.CharField(max_length=255, verbose_name='description')), 29 | ('body', models.TextField(verbose_name='article content')), 30 | ('banner_image', models.ImageField(default='/house_sample.jpg', upload_to='', verbose_name='banner image')), 31 | ('views', models.IntegerField(default=0, verbose_name='article views')), 32 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='articles', to=settings.AUTH_USER_MODEL, verbose_name='user')), 33 | ], 34 | options={ 35 | 'ordering': ['-created_at', '-updated_at'], 36 | 'abstract': False, 37 | }, 38 | ), 39 | migrations.CreateModel( 40 | name='Tag', 41 | fields=[ 42 | ('pkid', models.BigAutoField(editable=False, primary_key=True, serialize=False)), 43 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), 44 | ('created_at', models.DateTimeField(auto_now_add=True)), 45 | ('updated_at', models.DateTimeField(auto_now=True)), 46 | ('tag', models.CharField(max_length=80)), 47 | ('slug', models.SlugField(unique=True)), 48 | ], 49 | options={ 50 | 'ordering': ['tag'], 51 | }, 52 | ), 53 | migrations.CreateModel( 54 | name='ArticleViews', 55 | fields=[ 56 | ('pkid', models.BigAutoField(editable=False, primary_key=True, serialize=False)), 57 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), 58 | ('created_at', models.DateTimeField(auto_now_add=True)), 59 | ('updated_at', models.DateTimeField(auto_now=True)), 60 | ('ip', models.CharField(max_length=250, verbose_name='ip address')), 61 | ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='article_views', to='articles.article')), 62 | ], 63 | options={ 64 | 'verbose_name': 'Total views on Article', 65 | 'verbose_name_plural': 'Total Article Views', 66 | }, 67 | ), 68 | migrations.AddField( 69 | model_name='article', 70 | name='tags', 71 | field=models.ManyToManyField(related_name='articles', to='articles.Tag'), 72 | ), 73 | ] 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .idea 3 | .coverage* 4 | .DS_Store 5 | staticfiles 6 | # .envs/* 7 | # !.envs/.local/ 8 | 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # C extensions 15 | *.so 16 | 17 | # Distribution / packaging 18 | .Python 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | share/python-wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | MANIFEST 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | # Installer logs 44 | pip-log.txt 45 | pip-delete-this-directory.txt 46 | 47 | # Unit test / coverage reports 48 | htmlcov/ 49 | .tox/ 50 | .nox/ 51 | .coverage 52 | .coverage.* 53 | .cache 54 | nosetests.xml 55 | coverage.xml 56 | *.cover 57 | *.py,cover 58 | .hypothesis/ 59 | .pytest_cache/ 60 | cover/ 61 | 62 | # Translations 63 | *.mo 64 | *.pot 65 | 66 | # Django stuff: 67 | *.log 68 | local_settings.py 69 | db.sqlite3 70 | db.sqlite3-journal 71 | 72 | # Flask stuff: 73 | instance/ 74 | .webassets-cache 75 | 76 | # Scrapy stuff: 77 | .scrapy 78 | 79 | # Sphinx documentation 80 | docs/_build/ 81 | 82 | # PyBuilder 83 | .pybuilder/ 84 | target/ 85 | 86 | # Jupyter Notebook 87 | .ipynb_checkpoints 88 | 89 | # IPython 90 | profile_default/ 91 | ipython_config.py 92 | 93 | # pyenv 94 | # For a library or package, you might want to ignore these files since the code is 95 | # intended to run in multiple environments; otherwise, check them in: 96 | # .python-version 97 | 98 | # pipenv 99 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 100 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 101 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 102 | # install all needed dependencies. 103 | #Pipfile.lock 104 | 105 | # poetry 106 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 107 | # This is especially recommended for binary packages to ensure reproducibility, and is more 108 | # commonly ignored for libraries. 109 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 110 | #poetry.lock 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /core_apps/articles/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.contrib.auth import get_user_model 4 | from django_filters.rest_framework import DjangoFilterBackend 5 | from rest_framework import filters, generics, permissions, status 6 | from rest_framework.decorators import api_view, permission_classes 7 | from rest_framework.exceptions import NotFound 8 | from rest_framework.response import Response 9 | from rest_framework.views import APIView 10 | 11 | from core_apps.articles.models import Article, ArticleViews 12 | 13 | from .exceptions import UpdateArticle 14 | from .filters import ArticleFilter 15 | from .pagination import ArticlePagination 16 | from .permissions import IsOwnerOrReadOnly 17 | from .renderers import ArticleJSONRenderer, ArticlesJSONRenderer 18 | from .serializers import ( 19 | ArticleCreateSerializer, 20 | ArticleSerializer, 21 | ArticleUpdateSerializer, 22 | ) 23 | 24 | User = get_user_model() 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | class ArticleListAPIView(generics.ListAPIView): 30 | serializer_class = ArticleSerializer 31 | permission_classes = [ 32 | permissions.IsAuthenticated, 33 | ] 34 | queryset = Article.objects.all() 35 | renderer_classes = (ArticlesJSONRenderer,) 36 | pagination_class = ArticlePagination 37 | filter_backends = [DjangoFilterBackend, filters.OrderingFilter] 38 | filterset_class = ArticleFilter 39 | ordering_fields = ["created_at", "username"] 40 | 41 | 42 | class ArticleCreateAPIView(generics.CreateAPIView): 43 | permission_classes = [ 44 | permissions.IsAuthenticated, 45 | ] 46 | serializer_class = ArticleCreateSerializer 47 | renderer_classes = [ArticleJSONRenderer] 48 | 49 | def create(self, request, *args, **kwargs): 50 | user = request.user 51 | data = request.data 52 | data["author"] = user.pkid 53 | serializer = self.serializer_class(data=data, context={"request": request}) 54 | serializer.is_valid(raise_exception=True) 55 | serializer.save() 56 | logger.info( 57 | f"article {serializer.data.get('title')} created by {user.username}" 58 | ) 59 | return Response(serializer.data, status=status.HTTP_201_CREATED) 60 | 61 | 62 | class ArticleDetailView(APIView): 63 | renderer_classes = [ArticleJSONRenderer] 64 | permission_classes = [permissions.IsAuthenticated] 65 | 66 | def get(self, request, slug): 67 | article = Article.objects.get(slug=slug) 68 | x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") 69 | if x_forwarded_for: 70 | ip = x_forwarded_for.split(",")[0] 71 | else: 72 | ip = request.META.get("REMOTE_ADDR") 73 | 74 | if not ArticleViews.objects.filter(article=article, ip=ip).exists(): 75 | ArticleViews.objects.create(article=article, ip=ip) 76 | 77 | article.views += 1 78 | article.save() 79 | 80 | serializer = ArticleSerializer(article, context={"request": request}) 81 | 82 | return Response(serializer.data, status=status.HTTP_200_OK) 83 | 84 | 85 | @api_view(["PATCH"]) 86 | @permission_classes([permissions.IsAuthenticated]) 87 | def update_article_api_view(request, slug): 88 | try: 89 | article = Article.objects.get(slug=slug) 90 | except Article.DoesNotExist: 91 | raise NotFound("That article does not exist in our catalog") 92 | 93 | user = request.user 94 | if article.author != user: 95 | raise UpdateArticle 96 | 97 | if request.method == "PATCH": 98 | data = request.data 99 | serializer = ArticleUpdateSerializer(article, data=data, many=False) 100 | serializer.is_valid(raise_exception=True) 101 | serializer.save() 102 | return Response(serializer.data) 103 | 104 | 105 | class ArticleDeleteAPIView(generics.DestroyAPIView): 106 | permission_classes = [permissions.IsAuthenticated, IsOwnerOrReadOnly] 107 | queryset = Article.objects.all() 108 | lookup_field = "slug" 109 | 110 | def delete(self, request, *args, **kwargs): 111 | try: 112 | article = Article.objects.get(slug=self.kwargs.get("slug")) 113 | except Article.DoesNotExist: 114 | raise NotFound("That article does not exist in our catalog") 115 | 116 | delete_operation = self.destroy(request) 117 | data = {} 118 | if delete_operation: 119 | data["success"] = "Deletion was successful" 120 | 121 | else: 122 | data["failure"] = "Deletion failed" 123 | 124 | return Response(data=data) 125 | -------------------------------------------------------------------------------- /core_apps/articles/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from core_apps.articles.models import Article, ArticleViews 4 | from core_apps.comments.serializers import CommentListSerializer 5 | from core_apps.ratings.serializers import RatingSerializer 6 | 7 | from .custom_tag_field import TagRelatedField 8 | 9 | 10 | class ArticleViewsSerializer(serializers.ModelSerializer): 11 | class Meta: 12 | model = ArticleViews 13 | exclude = ["updated_at", "pkid"] 14 | 15 | 16 | class ArticleSerializer(serializers.ModelSerializer): 17 | author_info = serializers.SerializerMethodField(read_only=True) 18 | banner_image = serializers.SerializerMethodField() 19 | read_time = serializers.ReadOnlyField(source="article_read_time") 20 | ratings = serializers.SerializerMethodField() 21 | num_ratings = serializers.SerializerMethodField() 22 | average_rating = serializers.ReadOnlyField(source="get_average_rating") 23 | likes = serializers.ReadOnlyField(source="article_reactions.likes") 24 | dislikes = serializers.ReadOnlyField(source="article_reactions.dislikes") 25 | tagList = TagRelatedField(many=True, required=False, source="tags") 26 | comments = serializers.SerializerMethodField() 27 | num_comments = serializers.SerializerMethodField() 28 | created_at = serializers.SerializerMethodField() 29 | updated_at = serializers.SerializerMethodField() 30 | 31 | def get_banner_image(self, obj): 32 | return obj.banner_image.url 33 | 34 | def get_created_at(self, obj): 35 | now = obj.created_at 36 | formatted_date = now.strftime("%m/%d/%Y, %H:%M:%S") 37 | return formatted_date 38 | 39 | def get_updated_at(self, obj): 40 | then = obj.updated_at 41 | formatted_date = then.strftime("%m/%d/%Y, %H:%M:%S") 42 | return formatted_date 43 | 44 | def get_author_info(self, obj): 45 | return { 46 | "username": obj.author.username, 47 | "fullname": obj.author.get_full_name, 48 | "about_me": obj.author.profile.about_me, 49 | "profile_photo": obj.author.profile.profile_photo.url, 50 | "email": obj.author.email, 51 | "twitter_handle": obj.author.profile.twitter_handle, 52 | } 53 | 54 | def get_ratings(self, obj): 55 | reviews = obj.article_ratings.all() 56 | serializer = RatingSerializer(reviews, many=True) 57 | return serializer.data 58 | 59 | def get_num_ratings(self, obj): 60 | num_reviews = obj.article_ratings.all().count() 61 | return num_reviews 62 | 63 | def get_comments(self, obj): 64 | comments = obj.comments.all() 65 | serializer = CommentListSerializer(comments, many=True) 66 | return serializer.data 67 | 68 | def get_num_comments(self, obj): 69 | num_comments = obj.comments.all().count() 70 | return num_comments 71 | 72 | class Meta: 73 | model = Article 74 | fields = [ 75 | "id", 76 | "title", 77 | "slug", 78 | "tagList", 79 | "description", 80 | "body", 81 | "banner_image", 82 | "read_time", 83 | "author_info", 84 | "likes", 85 | "dislikes", 86 | "ratings", 87 | "num_ratings", 88 | "average_rating", 89 | "views", 90 | "num_comments", 91 | "comments", 92 | "created_at", 93 | "updated_at", 94 | ] 95 | 96 | 97 | class ArticleCreateSerializer(serializers.ModelSerializer): 98 | tags = TagRelatedField(many=True, required=False) 99 | banner_image = serializers.SerializerMethodField() 100 | created_at = serializers.SerializerMethodField() 101 | 102 | class Meta: 103 | model = Article 104 | exclude = ["updated_at", "pkid"] 105 | 106 | def get_created_at(self, obj): 107 | now = obj.created_at 108 | formatted_date = now.strftime("%m/%d/%Y, %H:%M:%S") 109 | return formatted_date 110 | 111 | def get_banner_image(self, obj): 112 | return obj.banner_image.url 113 | 114 | 115 | class ArticleUpdateSerializer(serializers.ModelSerializer): 116 | tags = TagRelatedField(many=True, required=False) 117 | updated_at = serializers.SerializerMethodField() 118 | 119 | class Meta: 120 | model = Article 121 | fields = ["title", "description", "body", "banner_image", "tags", "updated_at"] 122 | 123 | def get_updated_at(self, obj): 124 | then = obj.updated_at 125 | formatted_date = then.strftime("%m/%d/%Y, %H:%M:%S") 126 | return formatted_date 127 | -------------------------------------------------------------------------------- /authors_api/settings/base.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from pathlib import Path 3 | 4 | import environ 5 | 6 | env = environ.Env() 7 | 8 | ROOT_DIR = Path(__file__).resolve().parent.parent.parent 9 | 10 | APPS_DIR = ROOT_DIR / "core_apps" 11 | 12 | 13 | # SECURITY WARNING: don't run with debug turned on in production! 14 | DEBUG = env.bool("DJANGO_DEBUG", False) 15 | 16 | 17 | # Application definition 18 | 19 | DJANGO_APPS = [ 20 | "django.contrib.auth", 21 | "django.contrib.contenttypes", 22 | "django.contrib.sessions", 23 | "django.contrib.sites", 24 | "django.contrib.messages", 25 | "django.contrib.staticfiles", 26 | "django.contrib.admin", 27 | ] 28 | 29 | 30 | THIRD_PARTY_APPS = [ 31 | "rest_framework", 32 | "django_filters", 33 | "django_countries", 34 | "phonenumber_field", 35 | "drf_yasg", 36 | "corsheaders", 37 | "djcelery_email", 38 | "djoser", 39 | "rest_framework_simplejwt", 40 | "haystack", 41 | "drf_haystack", 42 | ] 43 | 44 | LOCAL_APPS = [ 45 | "core_apps.common", 46 | "core_apps.users", 47 | "core_apps.profiles", 48 | "core_apps.articles", 49 | "core_apps.favorites", 50 | "core_apps.reactions", 51 | "core_apps.ratings", 52 | "core_apps.comments", 53 | "core_apps.search", 54 | ] 55 | 56 | INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS 57 | 58 | MIDDLEWARE = [ 59 | "django.middleware.security.SecurityMiddleware", 60 | "corsheaders.middleware.CorsMiddleware", 61 | "whitenoise.middleware.WhiteNoiseMiddleware", 62 | "django.contrib.sessions.middleware.SessionMiddleware", 63 | "django.middleware.common.CommonMiddleware", 64 | "django.middleware.csrf.CsrfViewMiddleware", 65 | "django.contrib.auth.middleware.AuthenticationMiddleware", 66 | "django.contrib.messages.middleware.MessageMiddleware", 67 | "django.middleware.common.BrokenLinkEmailsMiddleware", 68 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 69 | ] 70 | 71 | ROOT_URLCONF = "authors_api.urls" 72 | 73 | TEMPLATES = [ 74 | { 75 | "BACKEND": "django.template.backends.django.DjangoTemplates", 76 | "DIRS": [str(APPS_DIR / "templates")], 77 | "APP_DIRS": True, 78 | "OPTIONS": { 79 | "context_processors": [ 80 | "django.template.context_processors.debug", 81 | "django.template.context_processors.request", 82 | "django.contrib.auth.context_processors.auth", 83 | "django.template.context_processors.i18n", 84 | "django.template.context_processors.static", 85 | "django.template.context_processors.tz", 86 | "django.contrib.messages.context_processors.messages", 87 | ], 88 | }, 89 | }, 90 | ] 91 | 92 | WSGI_APPLICATION = "authors_api.wsgi.application" 93 | 94 | 95 | DATABASES = {"default": env.db("DATABASE_URL")} 96 | DATABASES["default"]["ATOMIC_REQUESTS"] = True 97 | 98 | PASSWORD_HASHERS = [ 99 | "django.contrib.auth.hashers.Argon2PasswordHasher", 100 | "django.contrib.auth.hashers.PBKDF2PasswordHasher", 101 | "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", 102 | "django.contrib.auth.hashers.BCryptSHA256PasswordHasher", 103 | ] 104 | 105 | 106 | # Password validation 107 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 108 | 109 | AUTH_PASSWORD_VALIDATORS = [ 110 | { 111 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 112 | }, 113 | { 114 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 115 | }, 116 | { 117 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 118 | }, 119 | { 120 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 121 | }, 122 | ] 123 | 124 | 125 | # Internationalization 126 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 127 | 128 | LANGUAGE_CODE = "en-us" 129 | 130 | TIME_ZONE = "Africa/Kigali" 131 | 132 | USE_I18N = True 133 | 134 | USE_L10N = True 135 | 136 | USE_TZ = True 137 | 138 | SITE_ID = 1 139 | 140 | ADMIN_URL = "supersecret/" 141 | 142 | ADMINS = [("""Alpha Ogilo""", "api.imperfect@gmail.com")] 143 | 144 | MANAGERS = ADMINS 145 | 146 | 147 | # Static files (CSS, JavaScript, Images) 148 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 149 | 150 | STATIC_URL = "/staticfiles/" 151 | STATIC_ROOT = str(ROOT_DIR / "staticfiles") 152 | STATICFILES_DIRS = [] 153 | STATICFILES_FINDERS = [ 154 | "django.contrib.staticfiles.finders.FileSystemFinder", 155 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 156 | ] 157 | 158 | MEDIA_URL = "/mediafiles/" 159 | MEDIA_ROOT = str(ROOT_DIR / "mediafiles") 160 | # Default primary key field type 161 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 162 | 163 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 164 | 165 | 166 | CORS_URLS_REGEX = r"^/api/.*$" 167 | 168 | AUTH_USER_MODEL = "users.User" 169 | 170 | CELERY_BROKER_URL = env("CELERY_BROKER") 171 | CELERY_RESULT_BACKEND = env("CELERY_BACKEND") 172 | CELERY_TIMEZONE = "Africa/Kigali" 173 | CELERY_ACCEPT_CONTENT = ["json"] 174 | CELERY_TASK_SERIALIZER = "json" 175 | CELERY_RESULT_SERIALIZER = "json" 176 | 177 | REST_FRAMEWORK = { 178 | "EXCEPTION_HANDLER": "core_apps.common.exceptions.common_exception_handler", 179 | "NON_FIELD_ERRORS_KEY": "error", 180 | "DEFAULT_AUTHENTICATION_CLASSES": ( 181 | "rest_framework_simplejwt.authentication.JWTAuthentication", 182 | ), 183 | } 184 | 185 | 186 | SIMPLE_JWT = { 187 | "AUTH_HEADER_TYPES": ( 188 | "Bearer", 189 | "JWT", 190 | ), 191 | "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15), 192 | "REFRESH_TOKEN_LIFETIME": timedelta(days=1), 193 | "SIGNING_KEY": env("SIGNING_KEY"), 194 | "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", 195 | "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), 196 | } 197 | 198 | DJOSER = { 199 | "LOGIN_FIELD": "email", 200 | "USER_CREATE_PASSWORD_RETYPE": True, 201 | "USERNAME_CHANGED_EMAIL_CONFIMATION": True, 202 | "PASSWORD_CHANGED_EMAIL_CONFIRMATION": True, 203 | "SEND_CONFIRMATION_EMAIL": True, 204 | "PASSWORD_RESET_CONFIRM_URL": "password/reset/confirm/{uid}/{token}", 205 | "SET_PASSWORD_RETYPE": True, 206 | "PASSWORD_RESET_CONFIRM_RETYPE": True, 207 | "USERNAME_RESET_CONFIRM_URL": "email/reset/confirm/{uid}/{token}", 208 | "ACTIVATION_URL": "activate/{uid}/{token}", 209 | "SEND_ACTIVATION_EMAIL": True, 210 | "SERIALIZERS": { 211 | "user_create": "core_apps.users.serializers.CreateUserSerializer", 212 | "user": "core_apps.users.serializers.UserSerializer", 213 | "current_user": "core_apps.users.serializers.UserSerializer", 214 | "user_delete": "djoser.serializers.UserDeleteSerializer", 215 | }, 216 | } 217 | 218 | HAYSTACK_CONNECTIONS = { 219 | "default": { 220 | "ENGINE": "haystack.backends.whoosh_backend.WhooshEngine", 221 | "PATH": ROOT_DIR / "whoosh_index", 222 | } 223 | } 224 | HAYSTACK_SEARCH_RESULTS_PER_PAGE = 10 225 | 226 | HAYSTACK_SIGNAL_PROCESSOR = "haystack.signals.RealtimeSignalProcessor" 227 | 228 | LOGGING = { 229 | "version": 1, 230 | "disable_existing_loggers": False, 231 | "formatters": { 232 | "verbose": { 233 | "format": "%(levelname)s %(name)-12s %(asctime)s %(module)s " 234 | "%(process)d %(thread)d %(message)s" 235 | } 236 | }, 237 | "handlers": { 238 | "console": { 239 | "level": "DEBUG", 240 | "class": "logging.StreamHandler", 241 | "formatter": "verbose", 242 | } 243 | }, 244 | "root": {"level": "INFO", "handlers": ["console"]}, 245 | } 246 | -------------------------------------------------------------------------------- /core_apps/profiles/views.py: -------------------------------------------------------------------------------- 1 | from multiprocessing import context 2 | from unicodedata import name 3 | 4 | from django.contrib.auth import get_user_model 5 | from django.core.mail import send_mail 6 | from rest_framework import generics, permissions, status 7 | from rest_framework.decorators import api_view, permission_classes 8 | from rest_framework.exceptions import NotFound 9 | from rest_framework.response import Response 10 | from rest_framework.views import APIView 11 | 12 | from authors_api.settings.production import DEFAULT_FROM_EMAIL 13 | 14 | from .exceptions import CantFollowYourself, NotYourProfile 15 | from .models import Profile 16 | from .pagination import ProfilePagination 17 | from .renderers import ProfileJSONRenderer, ProfilesJSONRenderer 18 | from .serializers import FollowingSerializer, ProfileSerializer, UpdateProfileSerializer 19 | 20 | User = get_user_model() 21 | 22 | # @api_view(["GET"]) 23 | # @permission_classes([permissions.AllowAny]) 24 | # def get_all_profiles(request): 25 | # profiles = Profile.objects.all() 26 | # serializer = ProfileSerializer(profiles, many=True) 27 | # namespaced_response = {"profiles": serializer.data} 28 | # return Response(namespaced_response, status=status.HTTP_200_OK) 29 | 30 | # @api_view(["GET"]) 31 | # @permission_classes([permissions.AllowAny]) 32 | # def get_profile_details(request,username): 33 | # try: 34 | # user_profile = Profile.objects.get(user__username=username) 35 | # except Profile.DoesNotExist: 36 | # raise NotFound('A profile with this username does not exist...') 37 | 38 | # serializer = ProfileSerializer(user_profile, many=False) 39 | # formatted_response = {"profile": serializer.data} 40 | # return Response (formatted_response, status=status.HTTP_200_OK) 41 | 42 | 43 | class ProfileListAPIView(generics.ListAPIView): 44 | serializer_class = ProfileSerializer 45 | permission_classes = [permissions.IsAuthenticated] 46 | queryset = Profile.objects.all() 47 | renderer_classes = (ProfilesJSONRenderer,) 48 | pagination_class = ProfilePagination 49 | 50 | 51 | class ProfileDetailAPIView(generics.RetrieveAPIView): 52 | permission_classes = [permissions.IsAuthenticated] 53 | queryset = Profile.objects.select_related("user") 54 | serializer_class = ProfileSerializer 55 | renderer_classes = (ProfileJSONRenderer,) 56 | 57 | def retrieve(self, request, username, *args, **kwargs): 58 | try: 59 | profile = self.queryset.get(user__username=username) 60 | except Profile.DoesNotExist: 61 | raise NotFound("A profile with this username does not exist") 62 | 63 | serializer = self.serializer_class(profile, context={"request": request}) 64 | 65 | return Response(serializer.data, status=status.HTTP_200_OK) 66 | 67 | 68 | class UpdateProfileAPIView(APIView): 69 | permission_classes = [permissions.IsAuthenticated] 70 | queryset = Profile.objects.select_related("user") 71 | renderer_classes = [ProfileJSONRenderer] 72 | serializer_class = UpdateProfileSerializer 73 | 74 | def patch(self, request, username): 75 | try: 76 | self.queryset.get(user__username=username) 77 | except Profile.DoesNotExist: 78 | raise NotFound("A profile with this username does not exist") 79 | 80 | user_name = request.user.username 81 | if user_name != username: 82 | raise NotYourProfile 83 | 84 | data = request.data 85 | serializer = UpdateProfileSerializer( 86 | instance=request.user.profile, data=data, partial=True 87 | ) 88 | if serializer.is_valid(): 89 | serializer.save() 90 | return Response(serializer.data, status=status.HTTP_200_OK) 91 | 92 | 93 | @api_view(["GET"]) 94 | @permission_classes([permissions.IsAuthenticated]) 95 | def get_my_followers(request, username): 96 | try: 97 | specific_user = User.objects.get(username=username) 98 | except User.DoesNotExist: 99 | raise NotFound("User with that username does not exist") 100 | 101 | userprofile_instance = Profile.objects.get(user__pkid=specific_user.pkid) 102 | 103 | user_followers = userprofile_instance.followed_by.all() 104 | serializer = FollowingSerializer(user_followers, many=True) 105 | formatted_response = { 106 | "status_code": status.HTTP_200_OK, 107 | "followers": serializer.data, 108 | "num_of_followers": len(serializer.data), 109 | } 110 | 111 | return Response(formatted_response, status=status.HTTP_200_OK) 112 | 113 | 114 | class FollowUnfollowAPIView(generics.GenericAPIView): 115 | permission_classes = [permissions.IsAuthenticated] 116 | serializer_class = FollowingSerializer 117 | 118 | def get(self, request, username): 119 | try: 120 | specific_user = User.objects.get(username=username) 121 | except User.DoesNotExist: 122 | raise NotFound("User with that username does not exist") 123 | 124 | userprofile_instance = Profile.objects.get(user__pkid=specific_user.pkid) 125 | my_following_list = userprofile_instance.following_list() 126 | serializer = ProfileSerializer(my_following_list, many=True) 127 | formatted_response = { 128 | "status_code": status.HTTP_200_OK, 129 | "users_i_follow": serializer.data, 130 | "num_users_i_follow": len(serializer.data), 131 | } 132 | return Response(formatted_response, status=status.HTTP_200_OK) 133 | 134 | def post(self, request, username): 135 | try: 136 | specific_user = User.objects.get(username=username) 137 | except User.DoesNotExist: 138 | raise NotFound("User with that username does not exist") 139 | 140 | if specific_user.pkid == request.user.pkid: 141 | raise CantFollowYourself 142 | 143 | userprofile_instance = Profile.objects.get(user__pkid=specific_user.pkid) 144 | current_user_profile = request.user.profile 145 | 146 | if current_user_profile.check_following(userprofile_instance): 147 | formatted_response = { 148 | "status_code": status.HTTP_400_BAD_REQUEST, 149 | "errors": f"You already follow {specific_user.username}", 150 | } 151 | return Response(formatted_response, status=status.HTTP_400_BAD_REQUEST) 152 | 153 | current_user_profile.follow(userprofile_instance) 154 | 155 | subject = "A new user follows you" 156 | message = f"Hi there {specific_user.username}!!, the user {current_user_profile.user.username} now follows you" 157 | from_email = DEFAULT_FROM_EMAIL 158 | recipient_list = [specific_user.email] 159 | send_mail(subject, message, from_email, recipient_list, fail_silently=True) 160 | 161 | return Response( 162 | { 163 | "status_code": status.HTTP_200_OK, 164 | "detail": f"You now follow {specific_user.username}", 165 | } 166 | ) 167 | 168 | def delete(self, request, username): 169 | try: 170 | specific_user = User.objects.get(username=username) 171 | except User.DoesNotExist: 172 | raise NotFound("User with that username does not exist") 173 | 174 | userprofile_instance = Profile.objects.get(user__pkid=specific_user.pkid) 175 | current_user_profile = request.user.profile 176 | 177 | if not current_user_profile.check_following(userprofile_instance): 178 | formatted_response = { 179 | "status_code": status.HTTP_400_BAD_REQUEST, 180 | "errors": f"You do not follow {specific_user.username}", 181 | } 182 | return Response(formatted_response, status=status.HTTP_400_BAD_REQUEST) 183 | 184 | current_user_profile.unfollow(userprofile_instance) 185 | formatted_response = { 186 | "status_code": status.HTTP_200_OK, 187 | "detail": f"You have unfollowed {specific_user.username}", 188 | } 189 | return Response(formatted_response, status=status.HTTP_200_OK) 190 | --------------------------------------------------------------------------------