diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ac860214..8c576d6c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,6 +18,7 @@ concurrency: jobs: + build: name: "Build dev image" runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 1ca0570d..3e86709a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ redis-*.tgz db.sqlite3 .secrets.env *.spec +.secrets.env diff --git a/README.md b/README.md index 7bc0bc64..69256c0e 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ * Swagger in Django Admin Panel * Ready for deploy by one click * Separated configuration for dev and prod (requirements and settings) -* CI/CD: GitHub Actions/GitlabCI +* CI/CD: GitHub Actions * Redefined default User model (main.models.py) -* MailHog, Jaeger, RabbitMQ integrations +* Mailpit, Jaeger, RabbitMQ integrations * Multi-stage build for prod versions * PostgreSql Backup @@ -24,21 +24,27 @@ #### Clone the repo or click "Use this template" button: - git clone https://github.com/bandirom/django-blog.git - +```shell +git clone https://github.com/bandirom/django-blog.git +``` + #### Before running add your superuser email/password and project name in docker/prod/env/.data.env file - SUPERUSER_EMAIL=example@email.com - SUPERUSER_PASSWORD=secretp@ssword - PROJECT_TITLE=MyProject +```dotenv +SUPERUSER_EMAIL=example@email.com +SUPERUSER_PASSWORD=secretp@ssword +PROJECT_TITLE=MyProject +``` #### Run the local develop server: - docker-compose up -d --build - docker-compose logs -f +```shell +docker-compose up -d --build +docker-compose logs -f +``` -##### Server will bind 8000 port. You can get access to server by browser [http://localhost:8008](http://localhost:8008) +##### Server will run on 8000 port. You can get access to server by browser [http://localhost:8000](http://localhost:8000) Run django commands through exec: ```shell @@ -47,21 +53,40 @@ docker-compose exec web python manage.py makemigrations docker-compose exec web python manage.py shell ``` -##### For testing mail backend you can use MailHog service - docker-compose -f docker-compose.yml -f docker/modules/mailhog.yml up -d --build +Get access to the container +```shell +docker-compose exec web sh +``` + +##### For run mail smtp for local development you can use Mailpit service + +* Run Mailpit +```shell +docker-compose -f docker/modules/mailpit.yml up -d +``` Don't forget to set SMTP mail backend in settings + +```dotenv +# docker/dev/env/.email.env +EMAIL_HOST=mailpit_hostname +``` +Where `mailpit_hostname` is +* `docker.host.internal` for Window and macOS +* `gateway-host` for Linux OS +--- ### Production environment -If your server under LoadBalancer with SSL/TLS certificate you could run simple `prod.yml` configuration - - docker-compose -f prod.yml up -d --build +If your server under LoadBalancer or nginx with SSL/TLS certificate you can run `prod.yml` configuration +```shell +docker-compose -f prod.yml up -d --build +``` #### For set https connection you should have a domain name - In prod.certbot.yml: +**In prod.certbot.yml:** Change the envs: CERTBOT_EMAIL: your real email @@ -70,10 +95,12 @@ Change the envs: To set https for 2 and more nginx servers: - ENVSUBST_VARS: API UI - API: api.domain.com - UI: domain.com - -Run command: +```dotenv +ENVSUBST_VARS: API +API: api.your-domain.com +``` - docker-compose -f prod.yml -f prod.certbot.yml up -d --build +Run command: +```shell +docker-compose -f prod.yml -f prod.certbot.yml up -d --build +``` diff --git a/docker-compose.yml b/docker-compose.yml index 1c78876e..6070d3d7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,8 +54,6 @@ services: - redis_data:/data networks: - separated_network - logging: - driver: none healthcheck: test: [ "CMD", "redis-cli","ping" ] interval: 1m20s diff --git a/docker/dev/env/.email.env b/docker/dev/env/.email.env index a5da6019..d552824c 100644 --- a/docker/dev/env/.email.env +++ b/docker/dev/env/.email.env @@ -1,8 +1,7 @@ -EMAIL_HOST=gateway-host +EMAIL_HOST=host.docker.internal EMAIL_PORT=1025 EMAIL_USE_TLS=0 EMAIL_USE_SSL=0 DEFAULT_FROM_EMAIL="Localhost " EMAIL_HOST_USER EMAIL_HOST_PASSWORD -EMAIL_BACKEND=smtp diff --git a/docker/dev/env/.env b/docker/dev/env/.env index d2326f33..f41d7391 100644 --- a/docker/dev/env/.env +++ b/docker/dev/env/.env @@ -10,7 +10,7 @@ HEALTH_CHECK_URL=/application/health/ ENABLE_SILK=1 ENABLE_DEBUG_TOOLBAR=0 ENABLE_SENTRY=0 -FRONTEND_URL=http://192.168.59.111:8008 -BACKEND_URL=http://192.168.59.111:8008 +FRONTEND_URL=http://localhost:8008 +BACKEND_URL=http://localhost:8008 -CHAT_PROXY=http://192.168.59.111:8005/init/ +CHAT_PROXY=http://localhost:8005/init/ diff --git a/docker/modules/mailhog.yml b/docker/modules/mailhog.yml deleted file mode 100644 index 0eba8331..00000000 --- a/docker/modules/mailhog.yml +++ /dev/null @@ -1,9 +0,0 @@ -# SMTP Server for mail testing - -services: - mailhog: - image: mailhog/mailhog - ports: - - "1025:1025" # smtp server - - "8025:8025" # web ui. http://localhost:8025/ - restart: unless-stopped diff --git a/docker/modules/mailpit.yml b/docker/modules/mailpit.yml new file mode 100644 index 00000000..1bb89382 --- /dev/null +++ b/docker/modules/mailpit.yml @@ -0,0 +1,15 @@ +# SMTP Server for mail testing + +services: + mailpit: + image: axllent/mailpit + restart: unless-stopped + ports: + - target: 1025 + published: 1025 + protocol: tcp + mode: host + - target: 8025 + published: 8025 + protocol: tcp + mode: host diff --git a/docker/prod/env/.email.env b/docker/prod/env/.email.env deleted file mode 100644 index 622ac7ce..00000000 --- a/docker/prod/env/.email.env +++ /dev/null @@ -1,8 +0,0 @@ -EMAIL_HOST -EMAIL_PORT -EMAIL_USE_TLS=0 -EMAIL_USE_SSL=0 -DEFAULT_FROM_EMAIL -EMAIL_HOST_USER -EMAIL_HOST_PASSWORD -EMAIL_BACKEND=smtp diff --git a/docker/prod/env/.env b/docker/prod/env/.env index e372d34d..0c51c629 100644 --- a/docker/prod/env/.env +++ b/docker/prod/env/.env @@ -15,3 +15,11 @@ FRONTEND_URL=https://blog.jollymanager.com BACKEND_URL=https://blog.jollymanager.com CHAT_PROXY=https://chat.jollymanager.com/init/ JWT_COOKIE_DOMAIN=.jollymanager.com + +EMAIL_HOST +EMAIL_PORT +EMAIL_USE_TLS=0 +EMAIL_USE_SSL=0 +DEFAULT_FROM_EMAIL +EMAIL_HOST_USER +EMAIL_HOST_PASSWORD diff --git a/prod.yml b/prod.yml index 63b88d42..1e3f3382 100644 --- a/prod.yml +++ b/prod.yml @@ -3,9 +3,6 @@ x-variables: &variables services: blog: - build: - context: . - dockerfile: docker/prod/web/Dockerfile image: bandirom/blog:${APP_VERSION:-latest} volumes: - redis_socket:/redis_socket @@ -33,9 +30,6 @@ services: extra_hosts: - "gateway-host:172.17.0.1" celery: - build: - context: . - dockerfile: docker/prod/web/Dockerfile image: bandirom/blog:${APP_VERSION:-latest} entrypoint: "" command: celery -A src worker --beat -l info diff --git a/web/blog/templatetags/__init__.py b/web/api/email_service/__init__.py similarity index 100% rename from web/blog/templatetags/__init__.py rename to web/api/email_service/__init__.py diff --git a/web/api/email_service/base.py b/web/api/email_service/base.py new file mode 100644 index 00000000..1d433380 --- /dev/null +++ b/web/api/email_service/base.py @@ -0,0 +1,72 @@ +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Optional, TypedDict + +from django.contrib.auth import get_user_model +from django.utils.translation import get_language + +from main.tasks import send_information_email + +if TYPE_CHECKING: + from main.models import UserType + + +User: 'UserType' = get_user_model() + + +class EmailSendData(TypedDict): + subject: str + template_name: str + language: str + to_email: str + context: dict + from_email: Optional[str] + file_path_attachments: Optional[str] + + +class BaseEmailService(ABC): + def __init__(self, user: Optional[User] = None, language: Optional[str] = None, from_email: Optional[str] = None): + self._user = user + self._locale: str = language or get_language() + self.from_email = from_email + + @property + def locale(self) -> str: + return self._locale + + @property + def user(self) -> User: + assert self._user, 'User object were not provided' + return self._user + + @property + @abstractmethod + def template_name(self) -> str: + """Template email path""" + + @property + def to_email(self) -> str: + return self.user.email + + def email_context(self, **kwargs) -> dict: + """Provide dict with data for email template rendering""" + return {} + + @property + @abstractmethod + def email_subject(self) -> str: + """Provide email subject""" + + def get_email_data(self, **kwargs) -> EmailSendData: + return { + 'subject': self.email_subject, + 'template_name': self.template_name, + 'language': self.locale, + 'to_email': self.to_email, + 'context': self.email_context(**kwargs), + 'from_email': self.from_email, + 'file_path_attachments': None, + } + + def send_email(self, **kwargs): + kwargs: dict = self.get_email_data(**kwargs) + return send_information_email.apply_async(kwargs=kwargs) diff --git a/web/api/email_service/password_reset.py b/web/api/email_service/password_reset.py new file mode 100644 index 00000000..bb707eca --- /dev/null +++ b/web/api/email_service/password_reset.py @@ -0,0 +1,18 @@ +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + +from api.email_service.base import BaseEmailService + + +class PasswordResetService(BaseEmailService): + template_name = 'emails/password_reset.html' + + @property + def email_subject(self) -> str: + return _('Blog password reset') + + def email_context(self, reset_url: str, **kwargs) -> dict: + return { + 'full_name': self.user.full_name, + 'reset_url': reset_url, + } diff --git a/web/api/email_service/sign_up.py b/web/api/email_service/sign_up.py new file mode 100644 index 00000000..e04c522f --- /dev/null +++ b/web/api/email_service/sign_up.py @@ -0,0 +1,17 @@ +from django.utils.translation import gettext_lazy as _ + +from api.email_service.base import BaseEmailService + + +class SignUpEmailService(BaseEmailService): + template_name = 'emails/verify_email.html' + + @property + def email_subject(self) -> str: + return _('Blog register confirmation email') + + def email_context(self, activate_url: str, **kwargs) -> dict: + return { + 'full_name': self.user.full_name, + 'activate_url': activate_url, + } diff --git a/web/api/email_services.py b/web/api/email_services.py deleted file mode 100644 index 6e01eab8..00000000 --- a/web/api/email_services.py +++ /dev/null @@ -1,38 +0,0 @@ -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Optional - -from django.contrib.auth import get_user_model -from django.utils.translation import get_language - -from main.tasks import send_information_email - -if TYPE_CHECKING: - from main.models import UserType - - -User: 'UserType' = get_user_model() - - -class BaseEmailHandler(ABC): - TEMPLATE_NAME: str = NotImplemented - - def __init__(self, user: Optional[User] = None, language: Optional[str] = None): - self.user = user - self._locale: str = language or get_language() - - @property - def locale(self) -> str: - return self._locale - - def send_email(self): - kwargs = self.email_kwargs() - default_kwargs = { - 'template_name': self.TEMPLATE_NAME, - 'letter_language': self.locale, - } - kwargs.update(default_kwargs) - return send_information_email.apply_async(kwargs=kwargs) - - @abstractmethod - def email_kwargs(self, **kwargs) -> dict: - """Provide a dict with at least subject, to_email and context keys""" diff --git a/web/api/v1/actions/serializers.py b/web/api/v1/actions/serializers.py index 3bbd81f2..8e72c391 100644 --- a/web/api/v1/actions/serializers.py +++ b/web/api/v1/actions/serializers.py @@ -25,8 +25,6 @@ class FollowSerializer(serializers.Serializer): class UserFollowSerializer(serializers.ModelSerializer): - """For list of user following and followers""" - profile_url = serializers.URLField(source='get_absolute_url') follow = serializers.BooleanField() diff --git a/web/api/v1/auth_app/managers.py b/web/api/v1/auth_app/managers.py new file mode 100644 index 00000000..171b6e2d --- /dev/null +++ b/web/api/v1/auth_app/managers.py @@ -0,0 +1,66 @@ +from typing import TYPE_CHECKING, Optional + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.tokens import default_token_generator +from django.core import signing +from django.utils.encoding import force_bytes, force_str +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode +from rest_framework.exceptions import ValidationError + +from api.v1.auth_app.types import PasswordResetDTO + +if TYPE_CHECKING: + from main.models import UserType + + +User: 'UserType' = get_user_model() + + +class ConfirmationKeyManager: + def __init__(self): + self.max_age = 60 * 60 * 24 * settings.EMAIL_CONFIRMATION_EXPIRE_DAYS + + @staticmethod + def generate_key(user: User) -> str: + return signing.dumps(obj=user.pk) + + def get_user_from_key(self, key: str) -> Optional[User]: + try: + pk = signing.loads(key, max_age=self.max_age) + user = User.objects.get(id=pk) + except (signing.SignatureExpired, signing.BadSignature, User.DoesNotExist): + user = None + return user + + +class PasswordResetManager: + def __init__(self): + self.token_generator = default_token_generator + + def generate(self, user: User) -> PasswordResetDTO: + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = self.token_generator.make_token(user) + return PasswordResetDTO(uid=uid, token=token) + + def validate(self, uid: str, token: str, raise_exception: bool = True) -> User: + errors = [] + user = self._get_user_by_uid(uid) + if not user: + errors.append({'uid': ['Invalid value']}) + if user and not self._validate_token(user, token): + errors.append({'token': ['Invalid value']}) + if errors and raise_exception: + raise ValidationError(errors) + return user + + @staticmethod + def _get_user_by_uid(uid: str) -> Optional[User]: + try: + uid = force_str(urlsafe_base64_decode(uid)) + return User.objects.get(id=uid) + except (User.DoesNotExist, ValueError): + return None + + def _validate_token(self, user: User, token: str) -> bool: + return self.token_generator.check_token(user, token) diff --git a/web/api/v1/auth_app/oauth/__init__.py b/web/api/v1/auth_app/oauth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/web/api/v1/auth_app/oauth/base/__init__.py b/web/api/v1/auth_app/oauth/base/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/web/api/v1/auth_app/oauth/base/client.py b/web/api/v1/auth_app/oauth/base/client.py new file mode 100644 index 00000000..6b3bdc17 --- /dev/null +++ b/web/api/v1/auth_app/oauth/base/client.py @@ -0,0 +1,101 @@ +from urllib.parse import parse_qsl + +import requests +from django.utils.http import urlencode + +from api.v1.auth_app.oauth.base.exceptions import OAuth2Error + + +class OAuth2Client: + def __init__( + self, + request, + client_id, + client_secret, + access_token_method, + access_token_url, + callback_url, + scope, + scope_delimiter=" ", + headers=None, + basic_auth=False, + ): + self.request = request + self.access_token_method = access_token_method + self.access_token_url = access_token_url + self.callback_url = callback_url + self.client_id = client_id + self.client_secret = client_secret + self.scope = scope_delimiter.join(set(scope)) + self.state = None + self.headers = headers + self.basic_auth = basic_auth + + def get_redirect_url(self, authorization_url, extra_params): + params = { + "client_id": self.client_id, + "redirect_uri": self.callback_url, + "scope": self.scope, + "response_type": "code", + } + if self.state: + params["state"] = self.state + params.update(extra_params) + return "%s?%s" % (authorization_url, urlencode(params)) + + def get_access_token(self, code, pkce_code_verifier=None): + data = { + "redirect_uri": self.callback_url, + "grant_type": "authorization_code", + "code": code, + } + if self.basic_auth: + auth = requests.auth.HTTPBasicAuth(self.client_id, self.client_secret) + else: + auth = None + data.update( + { + "client_id": self.client_id, + "client_secret": self.client_secret, + } + ) + params = None + self._strip_empty_keys(data) + url = self.access_token_url + if self.access_token_method == "GET": + params = data + data = None + if data and pkce_code_verifier: + data["code_verifier"] = pkce_code_verifier + # TODO: Proper exception handling + resp = ( + get_adapter() + .get_requests_session() + .request( + self.access_token_method, + url, + params=params, + data=data, + headers=self.headers, + auth=auth, + ) + ) + + access_token = None + if resp.status_code in [200, 201]: + # Weibo sends json via 'text/plain;charset=UTF-8' + if resp.headers["content-type"].split(";")[0] == "application/json" or resp.text[:2] == '{"': + access_token = resp.json() + else: + access_token = dict(parse_qsl(resp.text)) + if not access_token or "access_token" not in access_token: + raise OAuth2Error("Error retrieving access token: %s" % resp.content) + return access_token + + def _strip_empty_keys(self, params): + """Added because the Dropbox OAuth2 flow doesn't + work when scope is passed in, which is empty. + """ + keys = [k for k, v in params.items() if v == ""] + for key in keys: + del params[key] diff --git a/web/api/v1/auth_app/oauth/base/exceptions.py b/web/api/v1/auth_app/oauth/base/exceptions.py new file mode 100644 index 00000000..8eaca1fa --- /dev/null +++ b/web/api/v1/auth_app/oauth/base/exceptions.py @@ -0,0 +1,5 @@ +from rest_framework.exceptions import ValidationError + + +class OAuth2Error(ValidationError): + pass diff --git a/web/api/v1/auth_app/oauth/base/factory.py b/web/api/v1/auth_app/oauth/base/factory.py new file mode 100644 index 00000000..34eb54bd --- /dev/null +++ b/web/api/v1/auth_app/oauth/base/factory.py @@ -0,0 +1,25 @@ +from collections import OrderedDict +from typing import Type + +from .provider import OAuth2Provider + + +class ProviderRegistry(object): + def __init__(self): + self.provider_map = OrderedDict() + self.loaded = False + + def get_classes(self): + return [{'name': name} for name, cls in self.provider_map.items() if cls().enabled] + + def register(self, cls): + self.provider_map[cls.name] = cls + + def get_class(self, provider_name: str) -> Type[OAuth2Provider]: + return self.provider_map.get(provider_name) + + def as_choices(self): + return [(provider_cls.name, provider_cls.name.capitalize()) for provider_cls in self.provider_map.values()] + + +provider_registry = ProviderRegistry() diff --git a/web/api/v1/auth_app/oauth/base/provider.py b/web/api/v1/auth_app/oauth/base/provider.py new file mode 100644 index 00000000..7c434ad6 --- /dev/null +++ b/web/api/v1/auth_app/oauth/base/provider.py @@ -0,0 +1,62 @@ +from abc import abstractmethod +from typing import TypeVar +from uuid import uuid4 + +from django.conf import settings + +from auth_app.models import SocialAccountProvider + + +class OAuth2Provider: + name: SocialAccountProvider = '' + + def __str__(self): + return self.name + + @property + def headers(self) -> dict: + return { + 'Content-Type': 'application/x-www-form-urlencoded', + } + + def authorization_header(self, token: str) -> dict: + return { + 'Authorization': f'Bearer {token}', + } + + def __init__(self): + self._name: str = self.name.upper() + self.config = settings.SOCIAL_ACCOUNTS_PROVIDERS.get(self.name) + self.enabled = self.config.get('enabled', True) + self.client_id = self.config['client_id'] + self.client_secret = self.config['client_secret'] + + @abstractmethod + def get_access_token(self, code: str): + pass + + @abstractmethod + def get_user_info(self, access_token: str): + pass + + @abstractmethod + def get_redirect_url(self, request): + pass + + def setup_state(self, request) -> str: + state = uuid4().hex + request.session[self._name] = {} + request.session[self._name]['state'] = state + return state + + def validate_state(self, request, expected_state: str) -> bool: + if self._name not in request.session: + return False + if 'state' not in request.session[self._name]: + return False + if request.session[self._name]['state'] != expected_state: + return False + return True + + +OAuth2ProviderT = TypeVar('OAuth2ProviderT', bound=OAuth2Provider) diff --git a/web/api/v1/auth_app/oauth/facebook/__init__.py b/web/api/v1/auth_app/oauth/facebook/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/web/api/v1/auth_app/oauth/facebook/provider.py b/web/api/v1/auth_app/oauth/facebook/provider.py new file mode 100644 index 00000000..3f74d104 --- /dev/null +++ b/web/api/v1/auth_app/oauth/facebook/provider.py @@ -0,0 +1,6 @@ +from api.v1.auth_app.oauth.base.provider import OAuth2Provider +from auth_app.models import SocialAccountProvider + + +class FacebookProvider(OAuth2Provider): + name = SocialAccountProvider.FACEBOOK diff --git a/web/api/v1/auth_app/oauth/google/__init__.py b/web/api/v1/auth_app/oauth/google/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/web/api/v1/auth_app/oauth/google/provider.py b/web/api/v1/auth_app/oauth/google/provider.py new file mode 100644 index 00000000..b24dba16 --- /dev/null +++ b/web/api/v1/auth_app/oauth/google/provider.py @@ -0,0 +1,75 @@ +from typing import NamedTuple +from urllib.parse import urlencode + +import requests +from django.conf import settings + +from api.v1.auth_app.oauth.base.exceptions import OAuth2Error +from api.v1.auth_app.oauth.base.factory import provider_registry +from api.v1.auth_app.oauth.base.provider import OAuth2Provider +from auth_app.models import SocialAccountProvider + + +class GoogleTokenData(NamedTuple): + access_token: str + expires_in: int + scope: str + token_type: str + id_token: str + + +class GoogleUserInfo(NamedTuple): + id: str + email: str + verified_email: bool + name: str + given_name: str + family_name: str + picture: str + locale: str + + +class GoogleProvider(OAuth2Provider): + name = SocialAccountProvider.GOOGLE + + def __init__(self): + super().__init__() + self.auth_redirect_url = 'https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount' + self.access_token_url = 'https://oauth2.googleapis.com/token' + self.authorize_url = 'https://accounts.google.com/o/oauth2/v2/auth' + self.user_info = 'https://www.googleapis.com/oauth2/v2/userinfo' + self.redirect_uri = settings.GOOGLE_REDIRECT_URI + + def get_access_token(self, code: str) -> GoogleTokenData: + data = { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': code, + 'redirect_uri': self.redirect_uri, + 'grant_type': 'authorization_code', + } + + response = requests.post(self.access_token_url, data=data, headers=self.headers) + if response.status_code != 200: + raise OAuth2Error() + return GoogleTokenData(**response.json()) + + def get_user_info(self, access_token: str) -> GoogleUserInfo: + response = requests.get(self.user_info, headers=self.authorization_header(access_token)) + if response.status_code != 200: + raise OAuth2Error() + return GoogleUserInfo(**response.json()) + + def get_redirect_url(self, request) -> str: + state = self.setup_state(request) + data = { + 'client_id': self.client_id, + 'redirect_uri': self.redirect_uri, + 'response_type': 'code', + 'scope': 'openid email profile', + 'state': state, + } + return f'{self.auth_redirect_url}?{urlencode(data)}' + + +provider_registry.register(GoogleProvider) diff --git a/web/api/v1/auth_app/oauth/google/serializers.py b/web/api/v1/auth_app/oauth/google/serializers.py new file mode 100644 index 00000000..477a9e0f --- /dev/null +++ b/web/api/v1/auth_app/oauth/google/serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers + + +class GoogleLoginSerializer(serializers.Serializer): + code = serializers.CharField() + state = serializers.CharField() + scope = serializers.CharField() diff --git a/web/api/v1/auth_app/oauth/google/views.py b/web/api/v1/auth_app/oauth/google/views.py new file mode 100644 index 00000000..7f77920b --- /dev/null +++ b/web/api/v1/auth_app/oauth/google/views.py @@ -0,0 +1,19 @@ +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from api.v1.auth_app.services import OAuthLoginService + +from .provider import GoogleProvider +from .serializers import GoogleLoginSerializer + + +class GoogleOAuth2CallbackView(GenericAPIView): + permission_classes = (AllowAny,) + serializer_class = GoogleLoginSerializer + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + service = OAuthLoginService(request, GoogleProvider()) + return service.login(code=serializer.data['code'], state=serializer.data['state']) diff --git a/web/api/v1/auth_app/oauth/serializers.py b/web/api/v1/auth_app/oauth/serializers.py new file mode 100644 index 00000000..018db1aa --- /dev/null +++ b/web/api/v1/auth_app/oauth/serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers + +from api.v1.auth_app.oauth.base.factory import provider_registry + + +class OAuth2RedirectSerializer(serializers.Serializer): + provider = serializers.ChoiceField(choices=provider_registry.as_choices()) diff --git a/web/api/v1/auth_app/oauth/urls.py b/web/api/v1/auth_app/oauth/urls.py new file mode 100644 index 00000000..44b79d7d --- /dev/null +++ b/web/api/v1/auth_app/oauth/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views +from .google.views import GoogleOAuth2CallbackView + +app_name = 'oauth_app' + +urlpatterns = [ + path('oauth2/redirect-url/', views.OAuth2RedirectView.as_view(), name='oauth2-redirect-url'), + path('oauth2/providers/', views.OAuth2ProviderListView.as_view(), name='oauth2-providers'), + path('google/sign-in/', GoogleOAuth2CallbackView.as_view(), name='google-sign-in'), +] diff --git a/web/api/v1/auth_app/oauth/views.py b/web/api/v1/auth_app/oauth/views.py new file mode 100644 index 00000000..5f95cae8 --- /dev/null +++ b/web/api/v1/auth_app/oauth/views.py @@ -0,0 +1,27 @@ +from drf_spectacular.utils import extend_schema +from rest_framework.response import Response +from rest_framework.views import APIView + +from . import serializers +from .base.factory import provider_registry + + +class OAuth2RedirectView(APIView): + permission_classes = () + + @extend_schema(parameters=[serializers.OAuth2RedirectSerializer]) + def get(self, request): + serializer = serializers.OAuth2RedirectSerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + provider = serializer.data['provider'] + provider = provider_registry.get_class(provider) + redirect_url: str = provider().get_redirect_url(request) + return Response(redirect_url) + + +class OAuth2ProviderListView(APIView): + permission_classes = () + + def get(self, request): + providers = provider_registry.get_classes() + return Response(providers) diff --git a/web/api/v1/auth_app/serializers.py b/web/api/v1/auth_app/serializers.py index c7ef517b..c52e7084 100644 --- a/web/api/v1/auth_app/serializers.py +++ b/web/api/v1/auth_app/serializers.py @@ -51,12 +51,15 @@ class PasswordResetSerializer(serializers.Serializer): email = serializers.EmailField() -class PasswordResetConfirmSerializer(serializers.Serializer): - password_1 = serializers.CharField(min_length=8, max_length=64) - password_2 = serializers.CharField(min_length=8, max_length=64) +class PasswordResetVerifySerializer(serializers.Serializer): uid = serializers.CharField() token = serializers.CharField() + +class PasswordResetConfirmSerializer(PasswordResetVerifySerializer): + password_1 = serializers.CharField(min_length=8, max_length=64) + password_2 = serializers.CharField(min_length=8, max_length=64) + def validate_password_1(self, password: str) -> str: validate_password(password) return password diff --git a/web/api/v1/auth_app/services.py b/web/api/v1/auth_app/services.py index 7f3c9f1a..7a2b8db4 100644 --- a/web/api/v1/auth_app/services.py +++ b/web/api/v1/auth_app/services.py @@ -1,15 +1,13 @@ -from datetime import date -from typing import TYPE_CHECKING, NamedTuple -from urllib.parse import urlencode, urljoin +from typing import TYPE_CHECKING +from urllib.parse import quote, urlencode, urljoin import requests from django.conf import settings from django.contrib.auth import authenticate, get_user_model -from django.contrib.auth.tokens import default_token_generator +from django.core.files.base import ContentFile from django.db import transaction +from django.db.models import Q from django.utils import timezone -from django.utils.encoding import force_bytes, force_str -from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode from django.utils.translation import gettext_lazy as _ from rest_framework import status from rest_framework.exceptions import ValidationError @@ -19,123 +17,74 @@ from rest_framework_simplejwt.settings import api_settings as jwt_settings from rest_framework_simplejwt.tokens import RefreshToken -from api.email_services import BaseEmailHandler +from api.email_service.password_reset import PasswordResetService +from api.email_service.sign_up import SignUpEmailService +from api.v1.auth_app.oauth.base.provider import OAuth2Provider +from api.v1.auth_app.oauth.google.provider import GoogleTokenData, GoogleUserInfo from api.v1.auth_app.utils import LoginResponseSerializer, get_client_ip +from auth_app.models import SocialAccount +from .managers import ConfirmationKeyManager, PasswordResetManager +from .oauth.base.exceptions import OAuth2Error +from .types import CreateUserData, PasswordResetConfirmData from main.decorators import except_shell -from main.models import GenderChoice -from main.tasks import send_information_email if TYPE_CHECKING: + from django.http import HttpResponse + from main.models import UserType User: 'UserType' = get_user_model() -class PasswordResetConfirmData(NamedTuple): - uid: str - token: str - password_1: str - password_2: str +class PasswordResetHandler: + def __init__(self, email: dict): + self.email = email + self.frontend_url = settings.FRONTEND_URL + self.frontend_path = '/reset/confirm' + def reset_password(self): + user = User.objects.get(email=self.email) + reset_url = self._get_reset_url(user) + PasswordResetService(user).send_email(reset_url=reset_url) -class CreateUserData(NamedTuple): - first_name: str - last_name: str - email: str - password_1: str - password_2: str - birthday: date = None - gender: GenderChoice = None + def _get_reset_url(self, user) -> str: + values = PasswordResetManager().generate(user) + url = urljoin(self.frontend_url, self.frontend_path) + reset_url = f'{url}?uid={values.uid}&token={values.token}' + return quote(reset_url, safe=':/?&=') -class ConfirmationEmailHandler(BaseEmailHandler): - FRONTEND_URL = settings.FRONTEND_URL - FRONTEND_PATH = '/confirm' - TEMPLATE_NAME = 'emails/verify_email.html' +class SignUpHandler: + def __init__(self, validated_data: dict): + self.data = CreateUserData(**validated_data) + self.frontend_url = settings.FRONTEND_URL + self.frontend_path = '/confirm' - def _get_activate_url(self) -> str: - url = urljoin(self.FRONTEND_URL, self.FRONTEND_PATH) - query_params: str = urlencode( - { - 'key': self.user.confirmation_key, - }, - safe=':+', + @transaction.atomic() + def create_user(self): + user = User.objects.create_user( + email=self.data.email, + first_name=self.data.first_name, + last_name=self.data.last_name, + password=self.data.password_1, + birthday=self.data.birthday, + gender=self.data.gender, + is_active=False, ) - return f'{url}?{query_params}' - - def email_kwargs(self, **kwargs) -> dict: - return { - 'subject': _('Register confirmation email'), - 'to_email': self.user.email, - 'context': { - 'user': self.user.full_name, - 'activate_url': self._get_activate_url(), - }, - } - + activate_url = self._get_activate_url(user) + SignUpEmailService(user).send_email(activate_url=activate_url) -class PasswordReset(BaseEmailHandler): - FRONTEND_URL = settings.FRONTEND_URL - TEMPLATE_NAME = 'emails/password_reset.html' - FRONTEND_PATH = '/reset/confirm' - - def _get_reset_url(self, uid: str, token: str) -> str: - url = urljoin(self.FRONTEND_URL, self.FRONTEND_PATH) + def _get_activate_url(self, user) -> str: + url = urljoin(self.frontend_url, self.frontend_path) query_params: str = urlencode( { - 'uid': uid, - 'token': token, + 'key': ConfirmationKeyManager.generate_key(user), }, safe=':+', ) return f'{url}?{query_params}' - def email_kwargs(self, **kwargs) -> dict: - uid = urlsafe_base64_encode(force_bytes(self.user.pk)) - token = default_token_generator.make_token(self.user) - reset_url = self._get_reset_url(uid=uid, token=token) - return { - 'subject': _('Password Reset'), - 'to_email': self.user.email, - 'context': { - 'user': self.user.full_name, - 'reset_url': reset_url, - }, - } - - -class PasswordResetConfirmHandler: - def __init__(self, *, uid: str, token: str): - self._token = token - self._uid = uid - self._user = None - - @property - def user(self) -> User: - return self._user - - def validate(self): - self._user = self._get_user_by_uid(self._uid) - self._validate_token() - - @staticmethod - def _get_user_by_uid(uid: str) -> User: - try: - uid = force_str(urlsafe_base64_decode(uid)) - return User.objects.get(id=uid) - except ( - User.DoesNotExist, - OverflowError, - TypeError, - ValueError, - ): - raise ValidationError({'uid': ['Invalid value']}) - - def _validate_token(self): - if not default_token_generator.check_token(self.user, self._token): - raise ValidationError({'token': ['Invalid value']}) - class LoginService: response_serializer = LoginResponseSerializer @@ -147,7 +96,7 @@ class LoginService: def __init__(self, request): self.request = request - self.rest_settings: dict = settings.REST_AUTH + self.settings: dict = settings.REST_AUTH def _authenticate(self, **kwargs: str): return authenticate(self.request, **kwargs) @@ -203,20 +152,20 @@ def __set_jwt_cookie(self, response, key: str, token_value: str, token_expiratio key=key, value=token_value, expires=token_expiration, - secure=self.rest_settings['JWT_AUTH_SECURE'], - httponly=self.rest_settings['JWT_AUTH_HTTPONLY'], - samesite=self.rest_settings['JWT_AUTH_SAMESITE'], - domain=self.rest_settings['JWT_COOKIE_DOMAIN'], + secure=self.settings['JWT_AUTH_SECURE'], + httponly=self.settings['JWT_AUTH_HTTPONLY'], + samesite=self.settings['JWT_AUTH_SAMESITE'], + domain=self.settings['JWT_COOKIE_DOMAIN'], ) - def _set_jwt_access_cookie(self, response, access_token): + def _set_jwt_access_cookie(self, response: "HttpResponse", access_token: str): access_token_expiration = timezone.now() + jwt_settings.ACCESS_TOKEN_LIFETIME - self.__set_jwt_cookie(response, self.rest_settings['JWT_AUTH_COOKIE'], access_token, access_token_expiration) + self.__set_jwt_cookie(response, self.settings['JWT_AUTH_COOKIE'], access_token, access_token_expiration) - def _set_jwt_refresh_cookie(self, response, refresh_token): + def _set_jwt_refresh_cookie(self, response: "HttpResponse", refresh_token: str): refresh_token_expiration = timezone.now() + jwt_settings.REFRESH_TOKEN_LIFETIME self.__set_jwt_cookie( - response, self.rest_settings['JWT_AUTH_REFRESH_COOKIE'], refresh_token, refresh_token_expiration + response, self.settings['JWT_AUTH_REFRESH_COOKIE'], refresh_token, refresh_token_expiration ) @@ -243,42 +192,24 @@ def validate_captcha(captcha: str, request) -> tuple: def get_user(email: str) -> User: return User.objects.get(email=email) - def password_reset(self, email: str) -> None: - user = self.get_user(email) - if not user: - return - PasswordReset(user).send_email() - - def password_reset_confirm(self, validated_data: dict) -> None: + @staticmethod + def password_reset_confirm(validated_data: dict) -> None: data = PasswordResetConfirmData(**validated_data) - handler = PasswordResetConfirmHandler(token=data.token, uid=data.uid) - handler.validate() - user = handler.user + manager = PasswordResetManager() + user = manager.validate(token=data.token, uid=data.uid) user.set_password(data.password_1) user.save(update_fields=['password']) - def verify_email_confirm(self, key: str): - user = User.from_key(key) + @staticmethod + def verify_email_confirm(key: str) -> User: + user = ConfirmationKeyManager().get_user_from_key(key) if not user: raise ValidationError({'key': _('Invalid or expired confirmation key')}) if user.is_active: raise ValidationError({'key': _('User already verified')}) user.is_active = True user.save(update_fields=['is_active']) - - @transaction.atomic() - def create_user(self, validated_data: dict): - data = CreateUserData(**validated_data) - user = User.objects.create_user( - email=data.email, - first_name=data.first_name, - last_name=data.last_name, - password=data.password_1, - birthday=data.birthday, - gender=data.gender, - is_active=False, - ) - ConfirmationEmailHandler(user).send_email() + return user def full_logout(request): @@ -317,3 +248,52 @@ def full_logout(request): response.data = {"detail": message} response.status_code = status.HTTP_200_OK return response + + +class OAuthLoginService: + def __init__(self, request, provider: OAuth2Provider): + self.request = request + self.provider = provider + + def create_social_account(self, user: User, uid: str): + return SocialAccount.objects.create( + user=user, + provider=self.provider.name, + uid=uid, + ) + + @staticmethod + def get_user_avatar(url: str) -> ContentFile: + response = requests.get(url, stream=True) + response.raise_for_status() + return ContentFile(response.content, name='google_image.png') + + @transaction.atomic() + def get_or_create_user(self, user_data: GoogleUserInfo) -> User: + if user := User.objects.filter(Q(social_accounts__uid=user_data.id) & Q(email=user_data.email)).first(): + return user + if user := User.objects.filter(email=user_data.email).first(): + self.create_social_account(user, user_data.id) + return user + user = User.objects.create_user( + email=user_data.email, + password=None, + first_name=user_data.given_name, + last_name=user_data.family_name, + avatar=self.get_user_avatar(user_data.picture), + is_active=user_data.verified_email, + ) + self.create_social_account(user, user_data.id) + return user + + def login(self, code: str, state: str): + if not self.provider.validate_state(self.request, state): + raise OAuth2Error('Invalid state') + + response_data: GoogleTokenData = self.provider.get_access_token(code) + + user_data: GoogleUserInfo = self.provider.get_user_info(response_data.access_token) + + user = self.get_or_create_user(user_data) + login_service = LoginService(self.request) + return login_service.get_response(user) diff --git a/web/api/v1/auth_app/types.py b/web/api/v1/auth_app/types.py new file mode 100644 index 00000000..30ed9f5d --- /dev/null +++ b/web/api/v1/auth_app/types.py @@ -0,0 +1,26 @@ +from datetime import date +from typing import NamedTuple, TypedDict + +from main.models import GenderChoice + + +class PasswordResetConfirmData(NamedTuple): + uid: str + token: str + password_1: str + password_2: str + + +class CreateUserData(NamedTuple): + first_name: str + last_name: str + email: str + password_1: str + password_2: str + birthday: date = None + gender: GenderChoice = None + + +class PasswordResetDTO(NamedTuple): + uid: str + token: str diff --git a/web/api/v1/auth_app/urls.py b/web/api/v1/auth_app/urls.py index 228a14f9..aa3822ab 100644 --- a/web/api/v1/auth_app/urls.py +++ b/web/api/v1/auth_app/urls.py @@ -1,4 +1,4 @@ -from django.urls import path +from django.urls import include, path from rest_framework_simplejwt.views import TokenRefreshView, TokenVerifyView from . import views @@ -11,7 +11,9 @@ path('sign-up/verify/', views.VerifyEmailView.as_view(), name='sign-up-verify'), path('logout/', views.LogoutView.as_view(), name='logout'), path('password/reset/', views.PasswordResetView.as_view(), name='reset-password'), + path('password/reset/verify/', views.PasswordResetVerifyView.as_view(), name='reset-password-verify'), path('password/reset/confirm/', views.PasswordResetConfirmView.as_view(), name='reset-password-confirm'), path('token/refresh/', TokenRefreshView.as_view()), path('token/verify/', TokenVerifyView.as_view()), + path('', include('api.v1.auth_app.oauth.urls')), ] diff --git a/web/api/v1/auth_app/views.py b/web/api/v1/auth_app/views.py index afe693aa..fb4a9a58 100644 --- a/web/api/v1/auth_app/views.py +++ b/web/api/v1/auth_app/views.py @@ -6,7 +6,8 @@ from rest_framework.views import APIView from . import serializers -from .services import AuthAppService, LoginService, full_logout +from .managers import PasswordResetManager +from .services import AuthAppService, LoginService, PasswordResetHandler, SignUpHandler, full_logout class SignUpView(GenericAPIView): @@ -17,8 +18,8 @@ def post(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - service = AuthAppService() - service.create_user(serializer.validated_data) + service = SignUpHandler(serializer.validated_data) + service.create_user() return Response( {'detail': _('Confirmation email has been sent')}, status=status.HTTP_201_CREATED, @@ -54,14 +55,30 @@ def post(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - service = AuthAppService() - service.password_reset(serializer.data['email']) + service = PasswordResetHandler(serializer.data['email']) + service.reset_password() return Response( {'detail': _('Password reset e-mail has been sent.')}, status=status.HTTP_200_OK, ) +class PasswordResetVerifyView(GenericAPIView): + serializer_class = serializers.PasswordResetVerifySerializer + permission_classes = (AllowAny,) + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + handler = PasswordResetManager() + handler.validate( + token=serializer.validated_data['token'], + uid=serializer.validated_data['uid'], + raise_exception=True, + ) + return Response({'detail': True}, status=status.HTTP_200_OK) + + class PasswordResetConfirmView(GenericAPIView): serializer_class = serializers.PasswordResetConfirmSerializer permission_classes = (AllowAny,) @@ -69,8 +86,7 @@ class PasswordResetConfirmView(GenericAPIView): def post(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - service = AuthAppService() - service.password_reset_confirm(serializer.validated_data) + AuthAppService.password_reset_confirm(serializer.validated_data) return Response( {'detail': _('Password has been reset with the new password.')}, status=status.HTTP_200_OK, @@ -84,8 +100,7 @@ class VerifyEmailView(GenericAPIView): def post(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - service = AuthAppService() - service.verify_email_confirm(key=serializer.data['key']) + AuthAppService.verify_email_confirm(key=serializer.data['key']) return Response( {'detail': _('Email verified')}, status=status.HTTP_200_OK, diff --git a/web/api/v1/blog/serializers.py b/web/api/v1/blog/serializers.py index 48cb1d55..3d2cb059 100644 --- a/web/api/v1/blog/serializers.py +++ b/web/api/v1/blog/serializers.py @@ -3,7 +3,7 @@ from actions.choices import LikeIconStatus, LikeStatus from actions.serializers import LikeDislikeRelationSerializer -from api.v1.blog.services import BlogService, CommentQueryService +from api.v1.blog.services import CommentQueryService from blog.models import Article, ArticleTag, Category, Comment from user_profile.serializers import ShortUserSerializer @@ -60,7 +60,10 @@ class Meta(ArticleSerializer.Meta): class CreateArticleSerializer(serializers.ModelSerializer): # TaggitSerializer, - # tags = TagListSerializerField() + tags = serializers.CharField() + + def validate_tags(self, tags: str) -> list[str]: + return tags.split(',') class Meta: model = Article @@ -69,17 +72,8 @@ class Meta: 'category', 'image', 'content', - ) # 'tags' - - def validate_title(self, title: str): - if BlogService.is_article_slug_exist(title): - raise serializers.ValidationError('This title already exists') - return title - - @transaction.atomic() - def create(self, validated_data: dict): - validated_data['author'] = self.context['request'].user - return super().create(validated_data) + 'tags', + ) class ParentCommentSerializer(serializers.ModelSerializer): diff --git a/web/api/v1/blog/services.py b/web/api/v1/blog/services.py index 04ce62f4..53fb1608 100644 --- a/web/api/v1/blog/services.py +++ b/web/api/v1/blog/services.py @@ -1,6 +1,10 @@ +from django.db import transaction from django.db.models import Count, Prefetch, Q, QuerySet +from rest_framework.exceptions import ValidationError +from slugify import slugify from api.v1.actions.services import LikeQueryService +from api.v1.blog.types import CreateArticleT from blog.choices import ArticleStatus from blog.models import Article, ArticleTag, Category, Comment @@ -66,19 +70,11 @@ def popular_tags(self) -> QuerySet[dict]: return tags -class BlogService: +class CategoryQueryService: @staticmethod def category_queryset() -> QuerySet[Category]: return Category.objects.all() - def get_active_articles(self) -> QuerySet[Article]: - return ( - Article.objects.select_related('category', 'author') - .prefetch_related('tags') - .filter(status=ArticleStatus.ACTIVE) - .annotate(comments_count=Count('comment_set')) - ) - @staticmethod def get_comments_queryset() -> QuerySet[Comment]: return ( @@ -100,6 +96,31 @@ def get_article(self, article_id: int) -> Article: def get_comment(comment_id: int): return Comment.objects.get(id=comment_id) + +class CreateArticleService: + + @staticmethod + def _get_slug(value: str) -> str: + return slugify(value) + @staticmethod - def is_article_slug_exist(title: str) -> bool: - return Article.objects.filter(slug=Article.get_slug(title)).exists() + def is_article_slug_exist(slug: str) -> bool: + return Article.objects.filter(slug=slug).exists() + + def _save_tags(self, tags: list[str], article: Article) -> None: + article_tags: list[ArticleTag] = [] + for name in tags: + article_tag = ArticleTag.objects.get_or_create(slug=self._get_slug(name), defaults={'name': name}) + article_tags.append(article_tag[0]) + article.tags.set(article_tags) + + @transaction.atomic + def create_article(self, author: 'UserType', article_data: CreateArticleT) -> Article: + print(f'{article_data=}') + slug = self._get_slug(article_data['title']) + if self.is_article_slug_exist(slug): + raise ValidationError('Article with this title already exists') + tags = article_data.pop('tags', []) + article = Article.objects.create(author=author, slug=slug, **article_data) + self._save_tags(tags, article) + return article diff --git a/web/api/v1/blog/types.py b/web/api/v1/blog/types.py new file mode 100644 index 00000000..c2ee090f --- /dev/null +++ b/web/api/v1/blog/types.py @@ -0,0 +1,14 @@ +from typing import TYPE_CHECKING, TypedDict + +if TYPE_CHECKING: + from django.core.files.uploadedfile import InMemoryUploadedFile + + from blog.models import Category + + +class CreateArticleT(TypedDict): + category: "Category" + title: str + content: str + image: "InMemoryUploadedFile" + tags: list[str] diff --git a/web/api/v1/blog/urls.py b/web/api/v1/blog/urls.py index f29359c8..28d899ee 100644 --- a/web/api/v1/blog/urls.py +++ b/web/api/v1/blog/urls.py @@ -6,6 +6,7 @@ urlpatterns = [ + path('categories/', views.CategoryListView.as_view(), name='category-list'), path('articles/', views.ArticleListView.as_view(), name='post-list'), path('articles/new/', views.CreateArticleView.as_view(), name='new-post'), path('articles//', views.ArticleDetailView.as_view(), name='post-detail'), diff --git a/web/api/v1/blog/views.py b/web/api/v1/blog/views.py index d53840e9..2bfc79ef 100644 --- a/web/api/v1/blog/views.py +++ b/web/api/v1/blog/views.py @@ -1,11 +1,10 @@ from rest_framework import status from rest_framework.generics import CreateAPIView, GenericAPIView, ListAPIView -from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from . import serializers from .filters import ArticleFilter -from .services import BlogQueryService, CommentQueryService, TagQueryService +from .services import BlogQueryService, CategoryQueryService, CommentQueryService, CreateArticleService, TagQueryService class ArticleListView(ListAPIView): @@ -35,8 +34,9 @@ class CreateArticleView(GenericAPIView): def post(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) + service = CreateArticleService() + article = service.create_article(request.user, serializer.validated_data) + return Response({'id': article.id}, status=status.HTTP_201_CREATED) class CommentListView(ListAPIView): @@ -59,3 +59,11 @@ class TagListView(ListAPIView): def get_queryset(self): return TagQueryService().popular_tags() + + +class CategoryListView(ListAPIView): + serializer_class = serializers.CategorySerializer + pagination_class = None + + def get_queryset(self): + return CategoryQueryService.category_queryset() diff --git a/web/api/v1/profile/serializers.py b/web/api/v1/profile/serializers.py index be0c2bda..c4d455fe 100644 --- a/web/api/v1/profile/serializers.py +++ b/web/api/v1/profile/serializers.py @@ -4,11 +4,14 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers +from auth_app.models import SocialAccount + User = get_user_model() class UserShortInfoSerializer(serializers.ModelSerializer): avatar = serializers.URLField(source='avatar_url') + # url = serializers.URLField(source='get_absolute_url') class Meta: @@ -26,7 +29,15 @@ class Meta: fields = ('id', 'first_name', 'last_name', 'birthday', 'gender') +class SocialAccountSerializer(serializers.ModelSerializer): + class Meta: + model = SocialAccount + fields = ('id', 'provider', 'connected') + + class UserSerializer(serializers.ModelSerializer): + social_accounts = SocialAccountSerializer(many=True) + class Meta: model = User fields = ( @@ -42,8 +53,9 @@ class Meta: 'followers_count', 'following_count', 'avatar', + 'social_accounts', ) - read_only_fields = ('full_name', 'user_likes', 'user_posts') + read_only_fields = ('full_name', 'user_likes', 'user_posts', 'social_accounts') class AvatarUpdateSerializer(serializers.ModelSerializer): diff --git a/web/api/v1/profile/services.py b/web/api/v1/profile/services.py index 446e05ab..e2c88455 100644 --- a/web/api/v1/profile/services.py +++ b/web/api/v1/profile/services.py @@ -32,7 +32,11 @@ def get_queryset(is_active: Optional[bool] = None) -> 'QuerySet[User]': def user_profile_queryset(self) -> 'QuerySet[User]': user_articles = Count('article_set', filter=Q(article_set__status=ArticleStatus.ACTIVE)) user_likes = Count('likes') - return self.get_queryset(is_active=True).annotate(user_posts=user_articles, user_likes=user_likes) + return ( + self.get_queryset(is_active=True) + .annotate(user_posts=user_articles, user_likes=user_likes) + .prefetch_related('social_accounts') + ) @staticmethod def exist_annotation(user) -> Exists: diff --git a/web/auth_app/migrations/0001_initial.py b/web/auth_app/migrations/0001_initial.py new file mode 100644 index 00000000..63a41fbe --- /dev/null +++ b/web/auth_app/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.9 on 2024-02-01 21:30 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SocialAccount', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.CharField(blank=True, max_length=40, null=True, unique=True)), + ('provider', models.CharField(choices=[('google', 'Google')], max_length=20)), + ('connected', models.DateTimeField(auto_now_add=True)), + ( + 'user', + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name='social_account', + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + 'indexes': [models.Index(fields=['provider', 'uid'], name='auth_app_so_provide_03d647_idx')], + }, + ), + migrations.AddConstraint( + model_name='socialaccount', + constraint=models.UniqueConstraint(fields=('user', 'provider'), name='unique_user_provider'), + ), + migrations.AddConstraint( + model_name='socialaccount', + constraint=models.UniqueConstraint(fields=('provider', 'uid'), name='unique_uid_provider'), + ), + ] diff --git a/web/auth_app/migrations/0002_alter_socialaccount_provider_and_more.py b/web/auth_app/migrations/0002_alter_socialaccount_provider_and_more.py new file mode 100644 index 00000000..f63325ad --- /dev/null +++ b/web/auth_app/migrations/0002_alter_socialaccount_provider_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.7 on 2024-02-25 19:11 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('auth_app', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='socialaccount', + name='provider', + field=models.CharField(choices=[('google', 'Google'), ('facebook', 'Facebook')], max_length=20), + ), + migrations.AlterField( + model_name='socialaccount', + name='user', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name='social_accounts', to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/web/auth_app/migrations/__init__.py b/web/auth_app/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/web/auth_app/models.py b/web/auth_app/models.py new file mode 100644 index 00000000..1371ddce --- /dev/null +++ b/web/auth_app/models.py @@ -0,0 +1,22 @@ +from django.db import models + + +class SocialAccountProvider(models.TextChoices): + GOOGLE = 'google' + FACEBOOK = 'facebook' + + +class SocialAccount(models.Model): + user = models.ForeignKey('main.User', on_delete=models.CASCADE, related_name='social_accounts') + uid = models.CharField(max_length=40, blank=True, null=True, unique=True) + provider = models.CharField(max_length=20, choices=SocialAccountProvider.choices) + connected = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=('user', 'provider'), name='unique_user_provider'), + models.UniqueConstraint(fields=('provider', 'uid'), name='unique_uid_provider'), + ] + indexes = [ + models.Index(fields=('provider', 'uid')), + ] diff --git a/web/auth_app/static/auth_app/js/googleOauth.js b/web/auth_app/static/auth_app/js/googleOauth.js new file mode 100644 index 00000000..2e5144ea --- /dev/null +++ b/web/auth_app/static/auth_app/js/googleOauth.js @@ -0,0 +1,46 @@ +$(function () { + googleCallbackHandler(); + $('#loginGoogle').click(googleLoginInit); +}); + + +function googleLoginInit(e) { + $.ajax({ + url: '/api/v1/auth/oauth2/redirect-url/?provider=google', + method: 'get', + success: function (redirect_url) { + window.location.href = redirect_url; + } + }) +} + + +function googleCallbackHandler() { + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code') + const scope = urlParams.get('scope') + const state = urlParams.get('state') + if (state && scope && state) { + const data = { + code: code, + scope: scope, + state: state, + } + $.ajax({ + url: '/api/v1/auth/google/sign-in/', + data: data, + method: 'post', + success: successGoogleCallback, + error: errorGoogleCallback, + }) + } +} + +function successGoogleCallback(data) { + console.log('successGoogleCallback', data) + window.location.href = '/profile/' +} + +function errorGoogleCallback(data) { + console.log('errorGoogleCallback', data) +} diff --git a/web/auth_app/static/auth_app/js/login.js b/web/auth_app/static/auth_app/js/login.js index 488d2e9d..20a60eb5 100644 --- a/web/auth_app/static/auth_app/js/login.js +++ b/web/auth_app/static/auth_app/js/login.js @@ -12,7 +12,6 @@ function login(e) { dataType: 'json', data: form.serialize(), success: function (data) { - localStorage.setItem('jwt', data.access_token) location.reload(); }, error: function (data) { diff --git a/web/auth_app/static/auth_app/js/resetConfirm.js b/web/auth_app/static/auth_app/js/resetConfirm.js index 6e0fd8f3..73d6a7ed 100644 --- a/web/auth_app/static/auth_app/js/resetConfirm.js +++ b/web/auth_app/static/auth_app/js/resetConfirm.js @@ -1,15 +1,14 @@ $(function () { + verifyToken(); $('#resetConfirmForm').submit(confirmReset); }); function confirmReset(e) { - let form = $(this); e.preventDefault(); const urlParams = new URLSearchParams(window.location.search); const data = { uid: urlParams.get('uid'), token: urlParams.get('token'), -// password_1: this.password_1.value, password_1: $("input[name=password_1]").val(), password_2: $("input[name=password_2]").val(), } @@ -54,3 +53,25 @@ function help_block(group, variable) { $(group).addClass(error_class_name); $(group).append('
' + variable + "
"); } + + +function verifyToken() { + const urlParams = new URLSearchParams(window.location.search); + const data = { + uid: urlParams.get('uid'), + token: urlParams.get('token'), + } + + $.ajax({ + url: "/api/v1/auth/password/reset/verify/", + type: "POST", + data: data, + success: function (data) { + + }, + error: function (data) { + $('#resetConfirm').empty() + $('#resetConfirm').append('

Link invalid or expired

') + }, + }) +} diff --git a/web/auth_app/static/auth_app/js/sign-up.js b/web/auth_app/static/auth_app/js/sign-up.js index 821b1fe6..f52fca72 100644 --- a/web/auth_app/static/auth_app/js/sign-up.js +++ b/web/auth_app/static/auth_app/js/sign-up.js @@ -17,7 +17,7 @@ function passwordVisibility() { } function singUp(e) { - let form = $(this); + const form = $(this); e.preventDefault(); $.ajax({ url: form.attr("action"), @@ -25,7 +25,7 @@ function singUp(e) { dataType: 'json', data: form.serialize(), success: function (data) { -// window.location.href = form.data('href'); + window.location.href = form.data('href'); }, error: function (data) { error_process(data); diff --git a/web/auth_app/templates/auth_app/googleRedirect.html b/web/auth_app/templates/auth_app/googleRedirect.html new file mode 100644 index 00000000..e7ec3799 --- /dev/null +++ b/web/auth_app/templates/auth_app/googleRedirect.html @@ -0,0 +1,7 @@ +{% extends 'auth_app/base.html'%} +{% load static %} + + +{% block jquery %} +$.getScript('{% static 'auth_app/js/googleOauth.js' %}'); +{% endblock %} diff --git a/web/auth_app/templates/auth_app/login.html b/web/auth_app/templates/auth_app/login.html index 56afa489..5cb86983 100644 --- a/web/auth_app/templates/auth_app/login.html +++ b/web/auth_app/templates/auth_app/login.html @@ -2,6 +2,7 @@ {% load static %} {% block head %} + {% endblock head %} @@ -35,6 +36,9 @@

Please Sign In

Register +
+ +
@@ -45,4 +49,5 @@

Please Sign In

{% block jquery %} $.getScript('{% static 'auth_app/js/login.js' %}'); +$.getScript('{% static 'auth_app/js/googleOauth.js' %}'); {% endblock %} diff --git a/web/auth_app/templates/auth_app/reset_password_confirm.html b/web/auth_app/templates/auth_app/reset_password_confirm.html index 02f81d72..035f8fe5 100644 --- a/web/auth_app/templates/auth_app/reset_password_confirm.html +++ b/web/auth_app/templates/auth_app/reset_password_confirm.html @@ -4,7 +4,7 @@ {% block container %}
-
+
{% csrf_token %}
diff --git a/web/auth_app/templates/auth_app/sign_up.html b/web/auth_app/templates/auth_app/sign_up.html index 21654693..85c63b78 100644 --- a/web/auth_app/templates/auth_app/sign_up.html +++ b/web/auth_app/templates/auth_app/sign_up.html @@ -10,15 +10,16 @@
- + Sign Up

It's free and always will be.

-
+ +
-
+ +
diff --git a/web/auth_app/urls.py b/web/auth_app/urls.py index 88eecd10..4246569b 100644 --- a/web/auth_app/urls.py +++ b/web/auth_app/urls.py @@ -22,4 +22,5 @@ TemplateAPIView.as_view(template_name='auth_app/verification_sent.html'), name='verify_email_sent', ), + path('auth/google', TemplateAPIView.as_view(template_name='auth_app/googleRedirect.html'), name='google_auth'), ] diff --git a/web/blog/filters.py b/web/blog/filters.py index 83a5e7b7..101e1c58 100644 --- a/web/blog/filters.py +++ b/web/blog/filters.py @@ -2,10 +2,6 @@ from django_filters import rest_framework as filters -class CharFilter(filters.BaseInFilter, filters.CharFilter): - pass - - class ArticleFilter(filters.FilterSet): search = filters.CharFilter(method='search_filter') diff --git a/web/blog/migrations/0003_remove_article_tags_alter_articletag_name_and_more.py b/web/blog/migrations/0003_remove_article_tags_alter_articletag_name_and_more.py new file mode 100644 index 00000000..440f9195 --- /dev/null +++ b/web/blog/migrations/0003_remove_article_tags_alter_articletag_name_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.11 on 2024-03-24 21:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0002_remove_comment_author'), + ] + + operations = [ + migrations.RemoveField( + model_name='article', + name='tags', + ), + migrations.AlterField( + model_name='articletag', + name='name', + field=models.CharField(max_length=100, unique=True), + ), + migrations.AlterField( + model_name='articletag', + name='slug', + field=models.SlugField(allow_unicode=True, max_length=100, unique=True), + ), + migrations.DeleteModel( + name='TaggedArticle', + ), + ] diff --git a/web/blog/migrations/0004_article_tags.py b/web/blog/migrations/0004_article_tags.py new file mode 100644 index 00000000..fff82d57 --- /dev/null +++ b/web/blog/migrations/0004_article_tags.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-03-24 21:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0003_remove_article_tags_alter_articletag_name_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='article', + name='tags', + field=models.ManyToManyField(blank=True, related_name='articles', to='blog.articletag'), + ), + ] diff --git a/web/blog/models.py b/web/blog/models.py index a0aba3c9..9d437eba 100644 --- a/web/blog/models.py +++ b/web/blog/models.py @@ -14,19 +14,15 @@ User = get_user_model() -class ArticleTag(TagBase): - objects = models.Manager() +class ArticleTag(models.Model): + name = models.CharField(unique=True, max_length=100) + slug = models.SlugField(unique=True, max_length=100, allow_unicode=True) class Meta: verbose_name = _('Tag') verbose_name_plural = _('Tags') -class TaggedArticle(TaggedItemBase): - content_object = models.ForeignKey('Article', on_delete=models.CASCADE, related_name='tagged_article') - tag = models.ForeignKey(ArticleTag, related_name='tagged_article', on_delete=models.CASCADE) - - class Category(models.Model): name = models.CharField(max_length=200) slug = models.SlugField(max_length=200, unique=True, allow_unicode=True) @@ -56,7 +52,7 @@ class Article(models.Model): status = models.PositiveSmallIntegerField(choices=ArticleStatus.choices, default=ArticleStatus.INACTIVE) image = models.ImageField(upload_to='articles/', blank=True, default='no-image-available.jpg') votes = GenericRelation(LikeDislike, related_query_name='articles') - tags = TaggableManager(through=TaggedArticle, related_name='article_tags', blank=True) + tags = models.ManyToManyField(ArticleTag, related_name='articles', blank=True) objects = models.Manager() @@ -67,14 +63,6 @@ def short_title(self) -> str: def __str__(self) -> str: return '{title} - {author}'.format(title=self.short_title, author=self.author) - @staticmethod - def get_slug(title: str) -> str: - return slugify(title, allow_unicode=True) - - def save(self, **kwargs): - self.slug = self.get_slug(self.title) - return super().save(**kwargs) - def get_absolute_url(self) -> str: return reverse_lazy('blog:blog-detail', kwargs={'slug': self.slug}) diff --git a/web/blog/static/blog/js/post_create.js b/web/blog/static/blog/js/post_create.js index e1bb5082..81fa9b12 100644 --- a/web/blog/static/blog/js/post_create.js +++ b/web/blog/static/blog/js/post_create.js @@ -1,18 +1,32 @@ $(function () { + $('.category-select').select2(); $('#createArticleForm').submit(postCreate); - + getCategories(); }); const error_class_name = "has-error" +function getCategories() { + $.ajax({ + url: '/api/v1/blog/categories/', + type: 'GET', + success: getCategoriesHandler, + }) +} + +function getCategoriesHandler (data) { + const selector = $('.category-select') + data.forEach((category) => selector.append(new Option(category.name, category.id))) +} + function postCreate(event) { event.preventDefault() let form = $(this) - let data = new FormData(form[0]); + const formData = new FormData(form[0]); $.ajax({ url: '/api/v1/blog/articles/new/', type: form.attr('method'), - data: data, + data: formData, contentType: false, processData: false, success: function (data) { diff --git a/web/blog/templates/blog/includes/sidebar_blocks/popular_tags.html b/web/blog/templates/blog/includes/sidebar_blocks/popular_tags.html index 2149b274..1bcf7c8f 100644 --- a/web/blog/templates/blog/includes/sidebar_blocks/popular_tags.html +++ b/web/blog/templates/blog/includes/sidebar_blocks/popular_tags.html @@ -1,5 +1,3 @@ -{% load blog %} -

Popular Tags:

diff --git a/web/blog/templates/blog/post_create.html b/web/blog/templates/blog/post_create.html index 8b60be05..6214cdf9 100644 --- a/web/blog/templates/blog/post_create.html +++ b/web/blog/templates/blog/post_create.html @@ -1,26 +1,27 @@ {% extends "blog/base.html" %} -{% load static main %} +{% load static %} {% block title %}New Post{% endblock title %} {% block head %} - + + {% endblock head %} {% block container %}

Create a new post

- +
- +
+
@@ -50,6 +51,7 @@

Create a new post

{% block script %} +