diff --git a/solution/api/v1/business/__init__.py b/solution/api/v1/business/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/api/v1/business/apps.py b/solution/api/v1/business/apps.py new file mode 100644 index 0000000..6cd5c3b --- /dev/null +++ b/solution/api/v1/business/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PingConfig(AppConfig): + name = "api.v1.business" + label = "api_v1_business" diff --git a/solution/api/v1/business/schemas.py b/solution/api/v1/business/schemas.py new file mode 100644 index 0000000..2b79e40 --- /dev/null +++ b/solution/api/v1/business/schemas.py @@ -0,0 +1,45 @@ +import uuid +from typing import ClassVar + +from ninja import ModelSchema, Schema +from pydantic import EmailStr + +from apps.business.models import Business + + +class BusinessSignUpIn(ModelSchema): + email: EmailStr + + class Meta: + model = Business + fields: ClassVar[list[str]] = [ + Business.name.field.name, + Business.password.field.name, + ] + + +class BusinessSignUpOut(Schema): + token: str + company_id: uuid.UUID + + +class BusinessSignInIn(ModelSchema): + email: EmailStr + + class Meta: + model = Business + fields: ClassVar[list[str]] = [ + Business.password.field.name, + ] + + +class BusinessSignInOut(Schema): + token: str + + +__all__ = [ + "BusinessSignInIn", + "BusinessSignInOut", + "BusinessSignUpIn", + "BusinessSignUpOut", +] diff --git a/solution/api/v1/business/views.py b/solution/api/v1/business/views.py new file mode 100644 index 0000000..b01e541 --- /dev/null +++ b/solution/api/v1/business/views.py @@ -0,0 +1,57 @@ +from http import HTTPStatus as status + +from django.http import HttpRequest +from ninja import Router +from ninja.errors import AuthenticationError + +from api.v1 import schemas as global_schemas +from api.v1.business import schemas +from apps.business.models import Business + +router = Router(tags=["business"]) + + +@router.post( + "/auth/sign-up", + response={ + status.OK: schemas.BusinessSignUpOut, + status.BAD_REQUEST: global_schemas.ValidationError, + status.CONFLICT: global_schemas.UniqueConstraintError, + }, +) +def signup( + request: HttpRequest, business: schemas.BusinessSignUpIn +) -> tuple[int, schemas.BusinessSignUpOut]: + business_obj = Business(**business.dict()) + business_obj.save() + + return status.OK, schemas.BusinessSignUpOut( + token=business_obj.generate_token(), company_id=business_obj.id + ) + + +@router.post( + "/auth/sign-in", + response={ + status.OK: schemas.BusinessSignInOut, + status.BAD_REQUEST: global_schemas.ValidationError, + status.UNAUTHORIZED: global_schemas.UnauthorizedError, + }, +) +def signin( + request: HttpRequest, login_data: schemas.BusinessSignInIn +) -> tuple[int, schemas.BusinessSignInOut]: + try: + business_obj = Business.objects.get(email=login_data.email) + except Business.DoesNotExist: + raise AuthenticationError from None + + if business_obj.password != login_data.password: + raise AuthenticationError + + business_obj.token_version += 1 + business_obj.save() + + return status.OK, schemas.BusinessSignInOut( + token=business_obj.generate_token() + ) diff --git a/solution/api/v1/handlers.py b/solution/api/v1/handlers.py index 40faccb..21dcf72 100644 --- a/solution/api/v1/handlers.py +++ b/solution/api/v1/handlers.py @@ -6,9 +6,28 @@ import django.http from django.http import HttpRequest, HttpResponse from ninja import NinjaAPI, errors +from config.errors import UniqueConstraintError + logger = logging.getLogger("django") +def handle_unique_constraint_error( + request: HttpRequest, + exc: UniqueConstraintError, + router: NinjaAPI, +) -> HttpResponse: + detail = list(exc.validation_error) + + if hasattr(exc, "error_dict"): + detail = dict(exc.validation_error) + + return router.create_response( + request, + {"detail": detail}, + status=status.CONFLICT, + ) + + def handle_django_validation_error( request: HttpRequest, exc: django.core.exceptions.ValidationError, @@ -22,7 +41,7 @@ def handle_django_validation_error( return router.create_response( request, {"detail": detail}, - status=status.UNPROCESSABLE_ENTITY, + status=status.BAD_REQUEST, ) @@ -42,7 +61,7 @@ def handle_validation_error( return router.create_response( request, {"detail": exc.errors}, - status=status.UNPROCESSABLE_ENTITY, + status=status.BAD_REQUEST, ) @@ -69,6 +88,7 @@ def handle_unknown_exception( exception_handlers = [ + (UniqueConstraintError, handle_unique_constraint_error), (django.core.exceptions.ValidationError, handle_django_validation_error), (errors.AuthenticationError, handle_authentication_error), (errors.ValidationError, handle_validation_error), diff --git a/solution/api/v1/router.py b/solution/api/v1/router.py index 1da4e50..3a1acdc 100644 --- a/solution/api/v1/router.py +++ b/solution/api/v1/router.py @@ -3,6 +3,7 @@ from functools import partial from ninja import NinjaAPI from api.v1 import handlers +from api.v1.business.views import router as business_router from api.v1.ping.views import router as ping_router router = NinjaAPI( @@ -20,6 +21,10 @@ router.add_router( "ping", ping_router, ) +router.add_router( + "business", + business_router, +) # Register exception handlers diff --git a/solution/api/v1/schemas.py b/solution/api/v1/schemas.py index 36a65e5..96aea26 100644 --- a/solution/api/v1/schemas.py +++ b/solution/api/v1/schemas.py @@ -9,3 +9,19 @@ class UnauthorizedError(Schema): class NotFoundError(Schema): detail: str = status.NOT_FOUND.phrase + + +class ValidationError(Schema): + detail: str + + +class UniqueConstraintError(Schema): + detail: str + + +__all__ = [ + "NotFoundError", + "UnauthorizedError", + "UniqueConstraintError", + "ValidationError", +] diff --git a/solution/apps/business/__init__.py b/solution/apps/business/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/apps/business/admin.py b/solution/apps/business/admin.py new file mode 100644 index 0000000..7db2d19 --- /dev/null +++ b/solution/apps/business/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from apps.business.models import Business + +admin.site.register(Business) diff --git a/solution/apps/business/apps.py b/solution/apps/business/apps.py new file mode 100644 index 0000000..a71375b --- /dev/null +++ b/solution/apps/business/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BusinessConfig(AppConfig): + name = "apps.business" + label = "business" diff --git a/solution/apps/business/migrations/0001_initial.py b/solution/apps/business/migrations/0001_initial.py new file mode 100644 index 0000000..7d85955 --- /dev/null +++ b/solution/apps/business/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.5 on 2025-01-20 11:21 + +import django.core.validators +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Business', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=50, validators=[django.core.validators.MinLengthValidator(5)])), + ('email', models.EmailField(max_length=120, unique=True, validators=[django.core.validators.MinLengthValidator(8)])), + ('password', models.CharField(max_length=60, validators=[django.core.validators.RegexValidator('^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$')])), + ('token_version', models.BigIntegerField(default=0)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/solution/apps/business/migrations/__init__.py b/solution/apps/business/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/apps/business/models.py b/solution/apps/business/models.py new file mode 100644 index 0000000..f290fb5 --- /dev/null +++ b/solution/apps/business/models.py @@ -0,0 +1,37 @@ +import jwt +from django.conf import settings +from django.core.validators import MinLengthValidator, RegexValidator +from django.db import models + +from apps.core.models import BaseModel + + +class Business(BaseModel): + name = models.CharField(max_length=50, validators=[MinLengthValidator(5)]) + email = models.EmailField( + unique=True, + max_length=120, + validators=[MinLengthValidator(8)], + ) + password = models.CharField( + max_length=60, + validators=[ + RegexValidator( + r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$" + ), + ], + ) + token_version = models.BigIntegerField(default=0) + + def __str__(self) -> str: + return self.name + + def generate_token(self) -> str: + return jwt.encode( + { + "business_id": str(self.id), + "token_version": self.token_version, + }, + settings.SECRET_KEY, + algorithm="HS256", + ) diff --git a/solution/apps/core/__init__.py b/solution/apps/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/apps/core/apps.py b/solution/apps/core/apps.py new file mode 100644 index 0000000..3a9b191 --- /dev/null +++ b/solution/apps/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = "apps.core" + label = "core" diff --git a/solution/apps/core/models.py b/solution/apps/core/models.py new file mode 100644 index 0000000..1918505 --- /dev/null +++ b/solution/apps/core/models.py @@ -0,0 +1,23 @@ +import uuid + +from django.core.exceptions import ValidationError +from django.db import models + +from config.errors import UniqueConstraintError + + +class BaseModel(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + class Meta: + abstract = True + + def save(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003 + self.full_clean(validate_unique=False) + + try: + self.validate_unique() + except ValidationError as e: + raise UniqueConstraintError(e) from None + + super().save(*args, **kwargs) diff --git a/solution/config/errors.py b/solution/config/errors.py new file mode 100644 index 0000000..f87d57e --- /dev/null +++ b/solution/config/errors.py @@ -0,0 +1,9 @@ +from django.core.exceptions import ValidationError + + +class UniqueConstraintError(Exception): + def __init__(self, validation_error: ValidationError) -> None: + self.validation_error = validation_error + + +__all__ = ["UniqueConstraintError"] diff --git a/solution/config/settings.py b/solution/config/settings.py index f681c35..ec23b17 100644 --- a/solution/config/settings.py +++ b/solution/config/settings.py @@ -414,8 +414,11 @@ INSTALLED_APPS = [ "django_guid", "ninja", # Internal apps + "apps.core", + "apps.business", # API v1 apps "api.v1.ping", + "api.v1.business", ] # GUID diff --git a/solution/pyproject.toml b/solution/pyproject.toml index 4ff701a..a84cd1a 100644 --- a/solution/pyproject.toml +++ b/solution/pyproject.toml @@ -14,6 +14,9 @@ dependencies = [ "gunicorn>=23.0.0", "httpx>=0.28.1", "psycopg2-binary>=2.9.10", + "pydantic>=2.10.5", + "pydantic[email]>=2.10.5", + "pyjwt>=2.10.1", "python-json-logger>=3.2.1", "redis>=5.2.1", ]