From 5c64e1f3b9400dd2bf109b4c7eab484633186aff Mon Sep 17 00:00:00 2001 From: ITQ Date: Mon, 1 Apr 2024 11:20:07 +0300 Subject: [PATCH] [chore] Global project refactoring --- .github/workflows/deploy.yml | 2 +- backend/.gitignore | 1 + backend/Makefile | 12 +- .../{notifications => api}/__init__.py | 0 backend/project/api/apps.py | 6 + .../migrations => api/core}/__init__.py | 0 backend/project/api/core/apps.py | 6 + .../core/migrations}/__init__.py | 0 backend/project/api/core/models.py | 14 ++ .../notifications}/__init__.py | 0 .../project/{ => api}/notifications/admin.py | 1 - .../project/{ => api}/notifications/apps.py | 0 .../project/{ => api}/notifications/forms.py | 1 - .../project/{ => api}/notifications/models.py | 10 +- .../{ => api}/notifications/serializers.py | 3 +- .../project/{ => api}/notifications/views.py | 5 +- .../project/{users => api/ping}/__init__.py | 0 backend/project/api/ping/apps.py | 6 + backend/project/api/ping/urls.py | 7 + backend/project/api/ping/views.py | 9 + .../migrations => api/teams}/__init__.py | 0 backend/project/{ => api}/teams/apps.py | 0 .../project/api/teams/migrations/__init__.py | 0 backend/project/{ => api}/teams/models.py | 36 +-- backend/project/{ => api}/teams/serializer.py | 1 - backend/project/{ => api}/teams/urls.py | 0 backend/project/{ => api}/teams/views.py | 1 - backend/project/api/urls.py | 6 + backend/project/api/users/__init__.py | 0 backend/project/{ => api}/users/admin.py | 2 +- backend/project/{ => api}/users/apps.py | 2 +- backend/project/api/users/authentication.py | 51 ++++ .../api/users/migrations/0001_initial.py | 238 ++++++++++++++++++ .../project/api/users/migrations/__init__.py | 0 backend/project/api/users/models.py | 94 +++++++ backend/project/api/users/serializers.py | 79 ++++++ backend/project/api/users/urls.py | 26 ++ backend/project/api/users/views.py | 131 ++++++++++ backend/project/config/settings.py | 33 +-- backend/project/config/urls.py | 56 ++--- .../notifications/migrations/0001_initial.py | 56 ----- backend/project/notifications/tests.py | 1 - .../project/users/migrations/0001_initial.py | 97 ------- .../0002_rename_technologies_user_skills.py | 18 -- backend/project/users/models.py | 128 ---------- backend/project/users/serializers.py | 26 -- backend/project/users/views.py | 47 ---- backend/requirements/prod.txt | 1 + docker-compose.yml | 14 -- 49 files changed, 731 insertions(+), 496 deletions(-) rename backend/project/{notifications => api}/__init__.py (100%) create mode 100644 backend/project/api/apps.py rename backend/project/{notifications/migrations => api/core}/__init__.py (100%) create mode 100644 backend/project/api/core/apps.py rename backend/project/{teams => api/core/migrations}/__init__.py (100%) create mode 100644 backend/project/api/core/models.py rename backend/project/{teams/migrations => api/notifications}/__init__.py (100%) rename backend/project/{ => api}/notifications/admin.py (99%) rename backend/project/{ => api}/notifications/apps.py (100%) rename backend/project/{ => api}/notifications/forms.py (99%) rename backend/project/{ => api}/notifications/models.py (68%) rename backend/project/{ => api}/notifications/serializers.py (99%) rename backend/project/{ => api}/notifications/views.py (99%) rename backend/project/{users => api/ping}/__init__.py (100%) create mode 100644 backend/project/api/ping/apps.py create mode 100644 backend/project/api/ping/urls.py create mode 100644 backend/project/api/ping/views.py rename backend/project/{users/migrations => api/teams}/__init__.py (100%) rename backend/project/{ => api}/teams/apps.py (100%) create mode 100644 backend/project/api/teams/migrations/__init__.py rename backend/project/{ => api}/teams/models.py (58%) rename backend/project/{ => api}/teams/serializer.py (99%) rename backend/project/{ => api}/teams/urls.py (100%) rename backend/project/{ => api}/teams/views.py (99%) create mode 100644 backend/project/api/urls.py create mode 100644 backend/project/api/users/__init__.py rename backend/project/{ => api}/users/admin.py (64%) rename backend/project/{ => api}/users/apps.py (84%) create mode 100644 backend/project/api/users/authentication.py create mode 100644 backend/project/api/users/migrations/0001_initial.py create mode 100644 backend/project/api/users/migrations/__init__.py create mode 100644 backend/project/api/users/models.py create mode 100644 backend/project/api/users/serializers.py create mode 100644 backend/project/api/users/urls.py create mode 100644 backend/project/api/users/views.py delete mode 100644 backend/project/notifications/migrations/0001_initial.py delete mode 100644 backend/project/notifications/tests.py delete mode 100644 backend/project/users/migrations/0001_initial.py delete mode 100644 backend/project/users/migrations/0002_rename_technologies_user_skills.py delete mode 100644 backend/project/users/models.py delete mode 100644 backend/project/users/serializers.py delete mode 100644 backend/project/users/views.py diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9c0f8f6..d93590d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,7 +9,7 @@ on: jobs: deploy: runs-on: self-hosted - if: ${{ github.event.workflow_run.conclusion == 'success' }} + if: ${{ github.event.workflow_run.conclusion == 'success' }} && ${{ github.ref == 'refs/heads/main' }} steps: - uses: actions/checkout@v3 diff --git a/backend/.gitignore b/backend/.gitignore index 85631f7..2156ea5 100755 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -162,3 +162,4 @@ cython_debug/ # Django stuff cache media +static/ diff --git a/backend/Makefile b/backend/Makefile index 40be1e3..a756af9 100755 --- a/backend/Makefile +++ b/backend/Makefile @@ -1,15 +1,15 @@ dump: - @cd project && python -Xutf8 manage.py dumpdata users --format json --indent 4 -o fixtures/users.json + @cd project && python -Xutf8 manage.py dumpdata --format json --indent 4 -o fixtures/data.json load: - @cd project && python -Xutf8 manage.py loaddata fixtures/users.json + @cd project && python -Xutf8 manage.py loaddata fixtures/data.json mig: @cd project && python manage.py makemigrations @cd project && python manage.py migrate check: test - @ruff check + @ruff check --fix test: @cd project && python manage.py test @@ -29,9 +29,5 @@ loc-c: help: @cd project && python manage.py help -fix: - ruff check --fix +sort: sort-requirements requirements/prod.txt requirements/test.txt requirements/dev.txt - -req: - @pip install -r requirements/dev.txt diff --git a/backend/project/notifications/__init__.py b/backend/project/api/__init__.py similarity index 100% rename from backend/project/notifications/__init__.py rename to backend/project/api/__init__.py diff --git a/backend/project/api/apps.py b/backend/project/api/apps.py new file mode 100644 index 0000000..878e7d5 --- /dev/null +++ b/backend/project/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api" diff --git a/backend/project/notifications/migrations/__init__.py b/backend/project/api/core/__init__.py similarity index 100% rename from backend/project/notifications/migrations/__init__.py rename to backend/project/api/core/__init__.py diff --git a/backend/project/api/core/apps.py b/backend/project/api/core/apps.py new file mode 100644 index 0000000..c0ce093 --- /dev/null +++ b/backend/project/api/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core" diff --git a/backend/project/teams/__init__.py b/backend/project/api/core/migrations/__init__.py similarity index 100% rename from backend/project/teams/__init__.py rename to backend/project/api/core/migrations/__init__.py diff --git a/backend/project/api/core/models.py b/backend/project/api/core/models.py new file mode 100644 index 0000000..0cecdf3 --- /dev/null +++ b/backend/project/api/core/models.py @@ -0,0 +1,14 @@ +from django.db import models + + +class AbstractTag(models.Model): + name = models.CharField( + max_length=255, + unique=True, + ) + + class Meta: + abstract = True + + def __str__(self): + return self.name diff --git a/backend/project/teams/migrations/__init__.py b/backend/project/api/notifications/__init__.py similarity index 100% rename from backend/project/teams/migrations/__init__.py rename to backend/project/api/notifications/__init__.py diff --git a/backend/project/notifications/admin.py b/backend/project/api/notifications/admin.py similarity index 99% rename from backend/project/notifications/admin.py rename to backend/project/api/notifications/admin.py index 20e80ea..4376c70 100644 --- a/backend/project/notifications/admin.py +++ b/backend/project/api/notifications/admin.py @@ -1,5 +1,4 @@ from django.contrib import admin - from notifications import models from notifications.forms import ( CreateNotificationAdminForm, diff --git a/backend/project/notifications/apps.py b/backend/project/api/notifications/apps.py similarity index 100% rename from backend/project/notifications/apps.py rename to backend/project/api/notifications/apps.py diff --git a/backend/project/notifications/forms.py b/backend/project/api/notifications/forms.py similarity index 99% rename from backend/project/notifications/forms.py rename to backend/project/api/notifications/forms.py index 2bf4ab4..d0ae6c2 100644 --- a/backend/project/notifications/forms.py +++ b/backend/project/api/notifications/forms.py @@ -1,5 +1,4 @@ from django import forms - from notifications.models import Notification diff --git a/backend/project/notifications/models.py b/backend/project/api/notifications/models.py similarity index 68% rename from backend/project/notifications/models.py rename to backend/project/api/notifications/models.py index 9dff371..ce283bf 100644 --- a/backend/project/notifications/models.py +++ b/backend/project/api/notifications/models.py @@ -14,31 +14,23 @@ class Notification(models.Model): settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="notifications", - verbose_name="пользователь", ) title = models.CharField( max_length=150, - verbose_name="заголовок", null=False, ) content = models.TextField( - verbose_name="содержание", + verbose_name="content", null=False, ) read = models.BooleanField( - "дата создания", default=False, ) created_at = models.DateTimeField( auto_now_add=True, - verbose_name="дата создания", ) objects = NotificationManager() - class Meta: - verbose_name = "уведомление" - verbose_name_plural = "уведомления" - def __str__(self): return self.title diff --git a/backend/project/notifications/serializers.py b/backend/project/api/notifications/serializers.py similarity index 99% rename from backend/project/notifications/serializers.py rename to backend/project/api/notifications/serializers.py index 8d16344..e999868 100644 --- a/backend/project/notifications/serializers.py +++ b/backend/project/api/notifications/serializers.py @@ -1,6 +1,5 @@ -from rest_framework import serializers - from notifications.models import Notification +from rest_framework import serializers class NotificationSerializer(serializers.ModelSerializer): diff --git a/backend/project/notifications/views.py b/backend/project/api/notifications/views.py similarity index 99% rename from backend/project/notifications/views.py rename to backend/project/api/notifications/views.py index 1ec91d1..3844856 100644 --- a/backend/project/notifications/views.py +++ b/backend/project/api/notifications/views.py @@ -1,8 +1,7 @@ -from rest_framework import generics -from rest_framework.permissions import IsAuthenticated - from notifications.models import Notification from notifications.serializers import NotificationSerializer +from rest_framework import generics +from rest_framework.permissions import IsAuthenticated class UserNotificationsAPIView(generics.ListAPIView): diff --git a/backend/project/users/__init__.py b/backend/project/api/ping/__init__.py similarity index 100% rename from backend/project/users/__init__.py rename to backend/project/api/ping/__init__.py diff --git a/backend/project/api/ping/apps.py b/backend/project/api/ping/apps.py new file mode 100644 index 0000000..f8b5f80 --- /dev/null +++ b/backend/project/api/ping/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api.ping" diff --git a/backend/project/api/ping/urls.py b/backend/project/api/ping/urls.py new file mode 100644 index 0000000..be0af73 --- /dev/null +++ b/backend/project/api/ping/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +import api.ping.views + +urlpatterns = [ + path("", api.ping.views.PingApiView.as_view(), name="ping"), +] diff --git a/backend/project/api/ping/views.py b/backend/project/api/ping/views.py new file mode 100644 index 0000000..4cb6154 --- /dev/null +++ b/backend/project/api/ping/views.py @@ -0,0 +1,9 @@ +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + + +class PingApiView(APIView): + def get(self, request): # noqa: ARG002 + data = "ok" + return Response(data, status=status.HTTP_200_OK) diff --git a/backend/project/users/migrations/__init__.py b/backend/project/api/teams/__init__.py similarity index 100% rename from backend/project/users/migrations/__init__.py rename to backend/project/api/teams/__init__.py diff --git a/backend/project/teams/apps.py b/backend/project/api/teams/apps.py similarity index 100% rename from backend/project/teams/apps.py rename to backend/project/api/teams/apps.py diff --git a/backend/project/api/teams/migrations/__init__.py b/backend/project/api/teams/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/project/teams/models.py b/backend/project/api/teams/models.py similarity index 58% rename from backend/project/teams/models.py rename to backend/project/api/teams/models.py index f1f72d7..b1ef70f 100644 --- a/backend/project/teams/models.py +++ b/backend/project/api/teams/models.py @@ -1,35 +1,26 @@ -import users.models from django.core import validators from django.db import models +from api.users.models import Skill, Specialization, User + class Vacancy(models.Model): name = models.CharField( max_length=255, - verbose_name="название вакансии", ) - start_date = models.DateField( - verbose_name="дата начала диапазона возраста участников", - blank=True, - null=True, - ) - - end_date = models.DateField( - verbose_name="дата конец диапазона возраста участников", + age_restriction = models.DateField( blank=True, null=True, ) specialization = models.ForeignKey( - users.models.Specialization, + Specialization, on_delete=models.CASCADE, blank=True, - verbose_name="специализация", null=True, ) skills = models.ManyToManyField( - users.models.Skill, + Skill, blank=True, - verbose_name="Технологии", ) def __str__(self): @@ -37,33 +28,24 @@ class Vacancy(models.Model): class Team(models.Model): - description = models.TextField( - verbose_name="описание команды", - ) - - name = models.CharField( - verbose_name="название команды", - max_length=255, - ) + name = models.CharField(max_length=255) + description = models.TextField() members = models.ManyToManyField( - users.models.User, + User, blank=True, unique=True, - verbose_name="участники", ) vacancies = models.ManyToManyField( Vacancy, blank=True, unique=True, - verbose_name="вакансии", ) avatar = models.ImageField( upload_to="teams_avatars", blank=True, - verbose_name="аватарка", ) count_of_members = models.IntegerField( @@ -88,7 +70,7 @@ class Team(models.Model): ) author = models.ForeignKey( - users.models.User, + User, on_delete=models.CASCADE, ) diff --git a/backend/project/teams/serializer.py b/backend/project/api/teams/serializer.py similarity index 99% rename from backend/project/teams/serializer.py rename to backend/project/api/teams/serializer.py index 5fd3f11..c7c19b3 100644 --- a/backend/project/teams/serializer.py +++ b/backend/project/api/teams/serializer.py @@ -1,5 +1,4 @@ from rest_framework import serializers - from teams.models import Team diff --git a/backend/project/teams/urls.py b/backend/project/api/teams/urls.py similarity index 100% rename from backend/project/teams/urls.py rename to backend/project/api/teams/urls.py diff --git a/backend/project/teams/views.py b/backend/project/api/teams/views.py similarity index 99% rename from backend/project/teams/views.py rename to backend/project/api/teams/views.py index 8beef7c..a583df5 100644 --- a/backend/project/teams/views.py +++ b/backend/project/api/teams/views.py @@ -2,7 +2,6 @@ from backend.project.users.models import User from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView - from teams.models import Team from .serializers import TeamSerializer diff --git a/backend/project/api/urls.py b/backend/project/api/urls.py new file mode 100644 index 0000000..6ac48ce --- /dev/null +++ b/backend/project/api/urls.py @@ -0,0 +1,6 @@ +from django.urls import include, path + +urlpatterns = [ + path("ping", include("api.ping.urls")), + path("auth", include("api.users.urls")), +] diff --git a/backend/project/api/users/__init__.py b/backend/project/api/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/project/users/admin.py b/backend/project/api/users/admin.py similarity index 64% rename from backend/project/users/admin.py rename to backend/project/api/users/admin.py index 267e900..fe19231 100644 --- a/backend/project/users/admin.py +++ b/backend/project/api/users/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from users.models import User +from api.users.models import User admin.site.register(User) diff --git a/backend/project/users/apps.py b/backend/project/api/users/apps.py similarity index 84% rename from backend/project/users/apps.py rename to backend/project/api/users/apps.py index 88f7b17..079b9a5 100644 --- a/backend/project/users/apps.py +++ b/backend/project/api/users/apps.py @@ -3,4 +3,4 @@ from django.apps import AppConfig class UsersConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "users" + name = "api.users" diff --git a/backend/project/api/users/authentication.py b/backend/project/api/users/authentication.py new file mode 100644 index 0000000..2caec7d --- /dev/null +++ b/backend/project/api/users/authentication.py @@ -0,0 +1,51 @@ +import bcrypt +import jwt +from django.conf import settings +from rest_framework.authentication import ( + BaseAuthentication, +) +from rest_framework.exceptions import AuthenticationFailed, NotAuthenticated +from rest_framework.permissions import IsAuthenticated + +from api.users.models import User + + +class JWTAuthentication(BaseAuthentication): + def authenticate_header(self, request): # noqa: ARG002 + return "Provide a valid token in the 'Authorization' header" + + def authenticate(self, request): + if IsAuthenticated not in getattr( + request.resolver_match.func.cls, "permission_classes", [] + ): + return None + + token = request.headers.get("Authorization", "").split("Bearer ")[-1] + + if not token: + raise NotAuthenticated + + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=["HS256"] + ) + + user = User.objects.get(id=payload["id"]) + + if not bcrypt.checkpw( + payload["password"].encode("utf-8"), + user.password.encode("utf-8"), + ): + error = "Token has expired" + raise AuthenticationFailed(error) + except User.DoesNotExist: + error = "Invalid token" + raise AuthenticationFailed(error) from None + except jwt.ExpiredSignatureError: + error = "Token has expired" + raise AuthenticationFailed(error) from None + except jwt.InvalidTokenError: + error = "Invalid token" + raise AuthenticationFailed(error) from None + else: + return (user, None) diff --git a/backend/project/api/users/migrations/0001_initial.py b/backend/project/api/users/migrations/0001_initial.py new file mode 100644 index 0000000..c9ee641 --- /dev/null +++ b/backend/project/api/users/migrations/0001_initial.py @@ -0,0 +1,238 @@ +# Generated by Django 4.2.11 on 2024-04-01 01:58 + +import api.users.models +import django.contrib.auth.models +import django.contrib.auth.validators +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="Achievements", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "file", + models.FileField( + upload_to=api.users.models.Achievements.get_file_path + ), + ), + ("info", models.TextField(max_length=255)), + ], + ), + migrations.CreateModel( + name="Skill", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, unique=True)), + ( + "level", + models.IntegerField( + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(10), + ] + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="Specialization", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255, unique=True)), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ("email", models.EmailField(max_length=254, unique=True)), + ("birthday", models.DateField(blank=True, null=True)), + ( + "avatar", + models.ImageField( + max_length=200, + null=True, + upload_to=api.users.models.User.get_file_path, + ), + ), + ("country", models.TextField(blank=True)), + ("city", models.TextField(blank=True)), + ( + "experience", + models.IntegerField( + null=True, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MinValueValidator(100), + ], + ), + ), + ( + "bio", + models.TextField( + blank=True, + validators=[django.core.validators.MaxLengthValidator(512)], + ), + ), + ( + "achievements", + models.ManyToManyField(blank=True, to="users.achievements"), + ), + ( + "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", + ), + ), + ("skills", models.ManyToManyField(blank=True, to="users.skill")), + ( + "specialization", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="users.specialization", + ), + ), + ( + "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", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/backend/project/api/users/migrations/__init__.py b/backend/project/api/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/project/api/users/models.py b/backend/project/api/users/models.py new file mode 100644 index 0000000..94ebb11 --- /dev/null +++ b/backend/project/api/users/models.py @@ -0,0 +1,94 @@ +import uuid + +from django.contrib.auth.models import AbstractUser +from django.core import validators +from django.db import models + +from api.core.models import AbstractTag + + +class Skill(AbstractTag): + level = models.IntegerField( + validators=[ + validators.MinValueValidator(1), + validators.MaxValueValidator(10), + ], + ) + + +class Achievements(models.Model): + def get_file_path(self, filename): + folder_name = str(uuid.uuid4()) + return f"achievements/{folder_name}/{filename}" + + file = models.FileField( # noqa: DJ012 + upload_to=get_file_path, + ) + info = models.TextField( + max_length=255, + ) + + def __str__(self): # noqa: DJ012 + return self.info + + +class Specialization(AbstractTag): + pass + + +class User(AbstractUser): + def get_file_path(self, filename): + folder_name = str(uuid.uuid4()) + return f"avatars/{folder_name}/{filename}" + + email = models.EmailField(unique=True) + birthday = models.DateField( + blank=True, + null=True, + ) + avatar = models.ImageField( + upload_to=get_file_path, + max_length=200, + null=True, + ) + country = models.TextField( + blank=True, + ) + city = models.TextField( + blank=True, + ) + experience = models.IntegerField( + validators=[ + validators.MinValueValidator(0), + validators.MinValueValidator(100), + ], + null=True, + ) + bio = models.TextField( + blank=True, + validators=[ + validators.MaxLengthValidator( + 512, + ), + ], + ) + + skills = models.ManyToManyField( + Skill, + blank=True, + ) + + achievements = models.ManyToManyField( + Achievements, + blank=True, + ) + + specialization = models.ForeignKey( + Specialization, + on_delete=models.SET_NULL, + blank=True, + null=True, + ) + + def __str__(self): + return self.username diff --git a/backend/project/api/users/serializers.py b/backend/project/api/users/serializers.py new file mode 100644 index 0000000..64ff146 --- /dev/null +++ b/backend/project/api/users/serializers.py @@ -0,0 +1,79 @@ +from django.contrib.auth.password_validation import validate_password +from rest_framework import serializers + +from api.users.models import User + + +class UserRegistrationSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = [ + "first_name", + "last_name", + "username", + "email", + "password", + "country", + "city", + ] + + def validate_password(self, value): + validate_password(value) + + return value + + +class UserLoginSerializer(serializers.Serializer): + remember_me = serializers.BooleanField(default=False, required=False) + username = serializers.CharField() + password = serializers.CharField() + + +class UserProfileSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ( + "first_name", + "last_name", + "username", + "email", + "birthday", + "country", + "city", + "bio", + "avatar", + "experience", + "specialization", + "achievements", + "skills", + ) + + +class UpdateProfileSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = [ + "countryCode", + "isPublic", + "phone", + "image", + ] + + def to_representation(self, instance): + data = super().to_representation(instance) + if data["image"] is None: + del data["image"] + if data["phone"] is None: + del data["phone"] + return data + + +class PasswordChangeSerializer(serializers.Serializer): + # ruff: noqa: N815 + old_password = serializers.CharField(required=True) + new_password = serializers.CharField(required=True) + + def validate_password(self, value): + validate_password(value) + + return value diff --git a/backend/project/api/users/urls.py b/backend/project/api/users/urls.py new file mode 100644 index 0000000..f6bc0e8 --- /dev/null +++ b/backend/project/api/users/urls.py @@ -0,0 +1,26 @@ +from django.urls import path + +import api.users.views + +urlpatterns = [ + path( + "/sign-up/", + api.users.views.SignupUserApiView.as_view(), + name="sign-up", + ), + path( + "/sign-in/", + api.users.views.SigninUserApiView.as_view(), + name="sign-in", + ), + path( + "/me/profile/", + api.users.views.ProfileMeApiView.as_view(), + name="profile-me", + ), + path( + "/me/updatePassword/", + api.users.views.PasswordChangeApiView.as_view(), + name="password-change", + ), +] diff --git a/backend/project/api/users/views.py b/backend/project/api/users/views.py new file mode 100644 index 0000000..7de57df --- /dev/null +++ b/backend/project/api/users/views.py @@ -0,0 +1,131 @@ +from datetime import timedelta + +import bcrypt +import jwt +from django.conf import settings +from django.utils import timezone +from rest_framework import status +from rest_framework.exceptions import ( + NotAuthenticated, + PermissionDenied, + ValidationError, +) +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from api.users.models import User +from api.users.serializers import ( + PasswordChangeSerializer, + UpdateProfileSerializer, + UserLoginSerializer, + UserProfileSerializer, + UserRegistrationSerializer, +) + + +class SignupUserApiView(APIView): + def post(self, request): + serializer = UserRegistrationSerializer(data=request.data) + + if serializer.is_valid(): + password = serializer.validated_data["password"] + password_hash = bcrypt.hashpw( + password.encode("utf-8"), bcrypt.gensalt() + ).decode("utf-8") + serializer.validated_data["password"] = password_hash + + serializer.save() + + return Response("ok", status=status.HTTP_201_CREATED) + + raise ValidationError(serializer.errors) + + +class SigninUserApiView(APIView): + def post(self, request): + serializer = UserLoginSerializer(data=request.data) + + if not serializer.is_valid(): + raise ValidationError(serializer.errors) + + username = serializer.validated_data.get("username") + password = serializer.validated_data.get("password") + + user = User.objects.filter(username=username).first() + + if user is not None: + if not bcrypt.checkpw( + password.encode("utf-8"), user.password.encode("utf-8") + ): + raise NotAuthenticated( + {"error": "Invalid credentials"}, + ) + else: + raise NotAuthenticated( + {"error": "Invalid credentials"}, + ) + + token = jwt.encode( + { + "id": user.id, + "password": password, + "exp": timezone.now() + timedelta(hours=24), + }, + settings.SECRET_KEY, + algorithm="HS256", + ) + + return Response({"token": token}) + + +class ProfileMeApiView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + serializer = UserProfileSerializer(request.user) + return Response(serializer.data) + + def patch(self, request): + user = request.user + serializer = UpdateProfileSerializer( + user, data=request.data, partial=True + ) + if serializer.is_valid(): + errors = User.check_unique(user.id, serializer.validated_data) + if errors: + return Response( + {"reason:": str(errors)}, status=status.HTTP_409_CONFLICT + ) + serializer.save() + + return Response(self._get_profile_data(user)) + + raise ValidationError(serializer.errors) + + +class PasswordChangeApiView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = PasswordChangeSerializer(data=request.data) + + if serializer.is_valid(): + old_password = serializer.validated_data.get("oldPassword") + new_password = serializer.validated_data.get("newPassword") + + if bcrypt.checkpw( + old_password.encode("utf-8"), + request.user.password.encode("utf-8"), + ): + password_hash = bcrypt.hashpw( + new_password.encode("utf-8"), bcrypt.gensalt() + ).decode("utf-8") + request.user.password = password_hash + request.user.save() + + return Response({"status": "ok"}, status=status.HTTP_200_OK) + + raise PermissionDenied({"error": "Invalid old password"}) + + raise ValidationError(serializer.errors) diff --git a/backend/project/config/settings.py b/backend/project/config/settings.py index 49aec92..346389d 100755 --- a/backend/project/config/settings.py +++ b/backend/project/config/settings.py @@ -33,26 +33,21 @@ MIGRATING = len(sys.argv) > 1 and ( ) -def register_debug_toolbar(): - INSTALLED_APPS.append("debug_toolbar") - MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware") - - INSTALLED_APPS = [ - # django apps + # Built-in apps "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - # third party apps + # Third-party apps "rest_framework", "rest_framework_simplejwt", "drf_yasg", - # project apps - "users.apps.UsersConfig", - "notifications.apps.NotificationsConfig", + # Developed apps + "api.ping.apps.PingConfig", + "api.users.apps.UsersConfig", ] MIDDLEWARE = [ @@ -131,23 +126,33 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] -LANGUAGE_CODE = "ru-ru" +LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" + USE_TZ = True + USE_I18N = True STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "static" + DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" AUTH_USER_MODEL = "users.User" REST_FRAMEWORK = { - "DEFAULT_AUTHENTICATION_CLASSES": [ - "rest_framework_simplejwt.authentication.JWTAuthentication", + "DEFAULT_FILTER_BACKENDS": [ + "django_filters.rest_framework.DjangoFilterBackend" ], + "DEFAULT_AUTHENTICATION_CLASSES": ( + "api.users.authentication.JWTAuthentication", + ), } +APPEND_SLASH = False + if DEBUG and not (TESTING or MIGRATING): - register_debug_toolbar() + INSTALLED_APPS.append("debug_toolbar") + MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware") diff --git a/backend/project/config/urls.py b/backend/project/config/urls.py index 0e46697..034ce2d 100755 --- a/backend/project/config/urls.py +++ b/backend/project/config/urls.py @@ -1,16 +1,9 @@ -import django.conf -import django.contrib.admin -import django.urls -import rest_framework_simplejwt.views -import users.views +from django.conf import settings +from django.contrib import admin from django.urls import include, path from drf_yasg import openapi from drf_yasg.views import get_schema_view -from rest_framework import permissions, routers -from users.views import UserViewSet - -router = routers.DefaultRouter() -router.register("users", UserViewSet) +from rest_framework import permissions schema_view = get_schema_view( openapi.Info(title="SkillHub API", default_version="v1"), @@ -20,6 +13,16 @@ schema_view = get_schema_view( urlpatterns = [ + # Built-in urls + path("admin/", admin.site.urls), + path( + "api-auth/", + include( + "rest_framework.urls", + namespace="rest_framework", + ), + ), + # API documentation path( "swagger/", schema_view.without_ui(cache_timeout=0), @@ -35,34 +38,9 @@ urlpatterns = [ schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc", ), - path("api/", include(router.urls)), - path("api/registration/", users.views.RegisterView.as_view()), - django.urls.path( - "api/token/", - rest_framework_simplejwt.views.TokenObtainPairView.as_view(), - name="token_obtain_pair", - ), - django.urls.path( - "api/token/refresh/", - rest_framework_simplejwt.views.TokenRefreshView.as_view(), - name="token_refresh", - ), - django.urls.path( - "api/token/verify/", - rest_framework_simplejwt.views.TokenVerifyView.as_view(), - name="token_verify", - ), - django.urls.path("admin/", django.contrib.admin.site.urls), + # API + path("api/", include("api.urls")), ] -if django.conf.settings.DEBUG and not ( - django.conf.settings.TESTING or django.conf.settings.MIGRATING -): - import debug_toolbar - - urlpatterns.append( - django.urls.path( - "__debug__/", - django.urls.include(debug_toolbar.urls), - ), - ) +if settings.DEBUG and not (settings.TESTING or settings.MIGRATING): + urlpatterns += (path("__debug__/", include("debug_toolbar.urls")),) diff --git a/backend/project/notifications/migrations/0001_initial.py b/backend/project/notifications/migrations/0001_initial.py deleted file mode 100644 index 3ee763c..0000000 --- a/backend/project/notifications/migrations/0001_initial.py +++ /dev/null @@ -1,56 +0,0 @@ -# Generated by Django 4.2.11 on 2024-03-31 19:42 - -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="Notification", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("title", models.CharField(max_length=150, verbose_name="заголовок")), - ("content", models.TextField(verbose_name="содержание")), - ( - "read", - models.BooleanField(default=False, verbose_name="дата создания"), - ), - ( - "created_at", - models.DateTimeField( - auto_now_add=True, verbose_name="дата создания" - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="notifications", - to=settings.AUTH_USER_MODEL, - verbose_name="пользователь", - ), - ), - ], - options={ - "verbose_name": "уведомление", - "verbose_name_plural": "уведомления", - }, - ), - ] diff --git a/backend/project/notifications/tests.py b/backend/project/notifications/tests.py deleted file mode 100644 index a39b155..0000000 --- a/backend/project/notifications/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/backend/project/users/migrations/0001_initial.py b/backend/project/users/migrations/0001_initial.py deleted file mode 100644 index 83ab923..0000000 --- a/backend/project/users/migrations/0001_initial.py +++ /dev/null @@ -1,97 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-31 12:22 - -import django.contrib.auth.models -import django.contrib.auth.validators -import django.core.validators -import django.db.models.deletion -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ] - - operations = [ - migrations.CreateModel( - name='Achievements', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, verbose_name='Название достижения')), - ('info', models.TextField(max_length=255, verbose_name='Информация про достижение')), - ('file', models.FileField(upload_to='achievements', verbose_name='Файл достижения')), - ], - ), - migrations.CreateModel( - name='Skill', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, unique=True)), - ('level', models.IntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(10)], verbose_name='уровень навыка')), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='Specialization', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, unique=True)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='Tag', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, unique=True)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='User', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('email', models.EmailField(max_length=254, unique=True, verbose_name='электронная почта')), - ('birthday', models.DateField(blank=True, help_text='Введите дату рождения', null=True, verbose_name='дата рождения')), - ('avatar', models.ImageField(blank=True, upload_to='avatars', verbose_name='Аватарка')), - ('country', models.CharField(blank=True, max_length=255, verbose_name='страна')), - ('city', models.CharField(blank=True, max_length=255, verbose_name='город')), - ('bio', models.TextField(blank=True, validators=[django.core.validators.MaxLengthValidator(512)], verbose_name='обо мне')), - ('experience', models.IntegerField(null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MinValueValidator(100)], verbose_name='опыт работы')), - ('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')), - ('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')), - ('achievements', models.ManyToManyField(blank=True, to='users.achievements', verbose_name='достижения')), - ('technologies', models.ManyToManyField(blank=True, to='users.skill', verbose_name='технологии')), - ('specialization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='users.specialization', verbose_name='специализация')), - ('tag', models.ManyToManyField(blank=True, to='users.tag', verbose_name='теги')), - ], - options={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', - 'abstract': False, - 'swappable': 'AUTH_USER_MODEL', - }, - managers=[ - ('objects', django.contrib.auth.models.UserManager()), - ], - ), - ] diff --git a/backend/project/users/migrations/0002_rename_technologies_user_skills.py b/backend/project/users/migrations/0002_rename_technologies_user_skills.py deleted file mode 100644 index 357356e..0000000 --- a/backend/project/users/migrations/0002_rename_technologies_user_skills.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.3 on 2024-03-31 13:48 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0001_initial'), - ] - - operations = [ - migrations.RenameField( - model_name='user', - old_name='technologies', - new_name='skills', - ), - ] diff --git a/backend/project/users/models.py b/backend/project/users/models.py deleted file mode 100644 index d07fa07..0000000 --- a/backend/project/users/models.py +++ /dev/null @@ -1,128 +0,0 @@ -from django.contrib.auth.models import AbstractUser -from django.core import validators -from django.db import models - - -class AbstractTag(models.Model): - name = models.CharField( - max_length=255, - unique=True, - ) - - class Meta: - abstract = True - - def __str__(self): - return self.name - - -class Tag(AbstractTag): - pass - - -class Skill(AbstractTag): - level = models.IntegerField( - validators=[ - validators.MinValueValidator(1), - validators.MaxValueValidator(10), - ], - verbose_name="уровень навыка", - ) - - -class Specialization(AbstractTag): - pass - - -class Achievements(models.Model): - name = models.CharField( - max_length=255, - verbose_name="Название достижения", - ) - - info = models.TextField( - max_length=255, - verbose_name="Информация про достижение", - ) - - file = models.FileField( - upload_to="achievements", - verbose_name="Файл достижения", - ) - - def __str__(self): - return self.name - - -class User(AbstractUser): - email = models.EmailField("электронная почта", unique=True) - - birthday = models.DateField( - verbose_name="дата рождения", - help_text="Введите дату рождения", - blank=True, - null=True, - ) - - avatar = models.ImageField( - upload_to="avatars", - blank=True, - verbose_name="Аватарка", - ) - - country = models.CharField( - blank=True, - max_length=255, - verbose_name="страна", - ) - - city = models.CharField( - blank=True, - max_length=255, - verbose_name="город", - ) - - bio = models.TextField( - blank=True, - validators=[ - validators.MaxLengthValidator( - 512, - ), - ], - verbose_name="обо мне", - ) - - skills = models.ManyToManyField( - Skill, - blank=True, - verbose_name="технологии", - ) - - tag = models.ManyToManyField( - Tag, - blank=True, - verbose_name="теги", - ) - - experience = models.IntegerField( - validators=[ - validators.MinValueValidator(0), - validators.MinValueValidator(100), - ], - verbose_name="опыт работы", - null=True, - ) - - achievements = models.ManyToManyField( - Achievements, - blank=True, - verbose_name="достижения", - ) - - specialization = models.ForeignKey( - Specialization, - on_delete=models.CASCADE, - blank=True, - verbose_name="специализация", - null=True, - ) diff --git a/backend/project/users/serializers.py b/backend/project/users/serializers.py deleted file mode 100644 index 5d4aaa9..0000000 --- a/backend/project/users/serializers.py +++ /dev/null @@ -1,26 +0,0 @@ -import rest_framework.serializers - -import users.models - - -class UserSerializer(rest_framework.serializers.ModelSerializer): - class Meta: - model = users.models.User - fields = ( - "email", - "birthday", - "country", - "city", - "bio", - "avatar", - "password", - "first_name", - "last_name", - "tag", - "specialization", - "experience", - "achievements", - "username", - ) - - extra_kwargs = {"password": {"write_only": True}} diff --git a/backend/project/users/views.py b/backend/project/users/views.py deleted file mode 100644 index 760fa35..0000000 --- a/backend/project/users/views.py +++ /dev/null @@ -1,47 +0,0 @@ -import rest_framework.generics -import rest_framework.permissions -import rest_framework.response -import rest_framework.viewsets - -import users.models -import users.serializers - - -class UserViewSet(rest_framework.viewsets.ModelViewSet): - http_method_names = ("get",) - - queryset = users.models.User.objects.all() - serializer_class = users.serializers.UserSerializer - permission_classes = [rest_framework.permissions.IsAuthenticated] - - -class RegisterView(rest_framework.generics.CreateAPIView): - http_method_names = ("post",) - serializer_class = users.serializers.UserSerializer - - def post(self, request): - if users.models.User.objects.filter( - username=request.data.get("username"), - ).exists(): - return rest_framework.response.Response( - { - "username": [ - "пользователь с таким именем уже существует.", - ], - }, - status=rest_framework.status.HTTP_409_CONFLICT, - ) - - serializer = self.get_serializer(data=request.data) - if serializer.is_valid(): - serializer.save() - - return rest_framework.response.Response( - serializer.data, - status=rest_framework.status.HTTP_201_CREATED, - ) - - return rest_framework.response.Response( - serializer.errors, - status=rest_framework.status.HTTP_400_BAD_REQUEST, - ) diff --git a/backend/requirements/prod.txt b/backend/requirements/prod.txt index e812bff..2e3883a 100644 --- a/backend/requirements/prod.txt +++ b/backend/requirements/prod.txt @@ -9,3 +9,4 @@ django-filter==24.2 Pillow==10.2.0 drf-yasg==1.21.7 setuptools +bcrypt==4.1.2 diff --git a/docker-compose.yml b/docker-compose.yml index 385d6ed..0e9dbac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,18 +51,6 @@ services: - "3000:3000" restart: always - # nginx: - # container_name: nginx - # image: nginx:custom - # build: ./nginx - # restart: unless-stopped - # ports: - # - 80:80 - # volumes: - # - media:/usr/src/app/media - # depends_on: - # - backend - pgadmin: image: dpage/pgadmin4:8.4 container_name: pgadmin @@ -82,5 +70,3 @@ volumes: postgres_data: redis_data: pgadmin_data: - media: - external: true