categorylanri import qilish qoshildi
This commit is contained in:
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
0
core/apps/__init__.py
Normal file
0
core/apps/__init__.py
Normal file
0
core/apps/accounts/__init__.py
Normal file
0
core/apps/accounts/__init__.py
Normal file
2
core/apps/accounts/admin/__init__.py
Normal file
2
core/apps/accounts/admin/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .core import * # noqa
|
||||
from .user import * # noqa
|
||||
18
core/apps/accounts/admin/core.py
Normal file
18
core/apps/accounts/admin/core.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Admin panel register
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth import models as db_models
|
||||
from django_core.models import SmsConfirm
|
||||
|
||||
from ..admin import user
|
||||
from .user import SmsConfirmAdmin
|
||||
|
||||
admin.site.unregister(db_models.Group)
|
||||
admin.site.register(db_models.Group, user.GroupAdmin)
|
||||
admin.site.register(db_models.Permission, user.PermissionAdmin)
|
||||
|
||||
admin.site.register(get_user_model(), user.CustomUserAdmin)
|
||||
admin.site.register(SmsConfirm, SmsConfirmAdmin)
|
||||
52
core/apps/accounts/admin/user.py
Normal file
52
core/apps/accounts/admin/user.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from django.contrib.auth import admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from unfold.admin import ModelAdmin
|
||||
from unfold.forms import AdminPasswordChangeForm # UserCreationForm,
|
||||
from unfold.forms import UserChangeForm
|
||||
|
||||
|
||||
class CustomUserAdmin(admin.UserAdmin, ModelAdmin):
|
||||
change_password_form = AdminPasswordChangeForm
|
||||
# add_form = UserCreationForm
|
||||
form = UserChangeForm
|
||||
list_display = (
|
||||
"first_name",
|
||||
"last_name",
|
||||
"phone",
|
||||
"role",
|
||||
)
|
||||
autocomplete_fields = ["groups", "user_permissions"]
|
||||
fieldsets = ((None, {"fields": ("phone",)}),) + (
|
||||
(None, {"fields": ("username", "password")}),
|
||||
(_("Personal info"), {"fields": ("first_name", "last_name", "email")}),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
"fields": (
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"groups",
|
||||
"user_permissions",
|
||||
"role",
|
||||
),
|
||||
},
|
||||
),
|
||||
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
|
||||
)
|
||||
|
||||
|
||||
class PermissionAdmin(ModelAdmin):
|
||||
list_display = ("name",)
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
class GroupAdmin(ModelAdmin):
|
||||
list_display = ["name"]
|
||||
search_fields = ["name"]
|
||||
autocomplete_fields = ("permissions",)
|
||||
|
||||
|
||||
class SmsConfirmAdmin(ModelAdmin):
|
||||
list_display = ["phone", "code", "resend_count", "try_count"]
|
||||
search_fields = ["phone", "code"]
|
||||
9
core/apps/accounts/apps.py
Normal file
9
core/apps/accounts/apps.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "core.apps.accounts"
|
||||
|
||||
def ready(self):
|
||||
from core.apps.accounts import signals # noqa
|
||||
1
core/apps/accounts/choices/__init__.py
Normal file
1
core/apps/accounts/choices/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .user import * # noqa
|
||||
12
core/apps/accounts/choices/user.py
Normal file
12
core/apps/accounts/choices/user.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class RoleChoice(models.TextChoices):
|
||||
"""
|
||||
User Role Choice
|
||||
"""
|
||||
|
||||
SUPERUSER = "superuser", _("Superuser")
|
||||
ADMIN = "admin", _("Admin")
|
||||
USER = "user", _("User")
|
||||
1
core/apps/accounts/managers/__init__.py
Normal file
1
core/apps/accounts/managers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .user import * # noqa
|
||||
23
core/apps/accounts/managers/user.py
Normal file
23
core/apps/accounts/managers/user.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.contrib.auth import base_user
|
||||
|
||||
|
||||
class UserManager(base_user.BaseUserManager):
|
||||
def create_user(self, phone, password=None, **extra_fields):
|
||||
if not phone:
|
||||
raise ValueError("The phone number must be set")
|
||||
|
||||
user = self.model(phone=phone, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_superuser(self, phone, password=None, **extra_fields):
|
||||
extra_fields.setdefault("is_staff", True)
|
||||
extra_fields.setdefault("is_superuser", True)
|
||||
|
||||
if extra_fields.get("is_staff") is not True:
|
||||
raise ValueError("Superuser must have is_staff=True.")
|
||||
if extra_fields.get("is_superuser") is not True:
|
||||
raise ValueError("Superuser must have is_superuser=True.")
|
||||
|
||||
return self.create_user(phone, password, **extra_fields)
|
||||
60
core/apps/accounts/migrations/0001_initial.py
Normal file
60
core/apps/accounts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# Generated by Django 5.1.3 on 2024-12-13 19:04
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
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='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')),
|
||||
('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')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('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')),
|
||||
('phone', models.CharField(max_length=255, unique=True)),
|
||||
('username', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('validated_at', models.DateTimeField(blank=True, null=True)),
|
||||
('role', models.CharField(choices=[('superuser', 'Superuser'), ('admin', 'Admin'), ('user', 'User')], default='user', max_length=255)),
|
||||
('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')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ResetToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('token', models.CharField(max_length=255, unique=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Reset Token',
|
||||
'verbose_name_plural': 'Reset Tokens',
|
||||
},
|
||||
),
|
||||
]
|
||||
0
core/apps/accounts/migrations/__init__.py
Normal file
0
core/apps/accounts/migrations/__init__.py
Normal file
3
core/apps/accounts/models/__init__.py
Normal file
3
core/apps/accounts/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# isort: skip_file
|
||||
from .user import * # noqa
|
||||
from .reset_token import * # noqa
|
||||
15
core/apps/accounts/models/reset_token.py
Normal file
15
core/apps/accounts/models/reset_token.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django_core.models import AbstractBaseModel
|
||||
|
||||
|
||||
class ResetToken(AbstractBaseModel):
|
||||
token = models.CharField(max_length=255, unique=True)
|
||||
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return self.token
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Reset Token"
|
||||
verbose_name_plural = "Reset Tokens"
|
||||
24
core/apps/accounts/models/user.py
Normal file
24
core/apps/accounts/models/user.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.contrib.auth import models as auth_models
|
||||
from django.db import models
|
||||
|
||||
from ..choices import RoleChoice
|
||||
from ..managers import UserManager
|
||||
|
||||
|
||||
class User(auth_models.AbstractUser):
|
||||
phone = models.CharField(max_length=255, unique=True)
|
||||
username = models.CharField(max_length=255, null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
validated_at = models.DateTimeField(null=True, blank=True)
|
||||
role = models.CharField(
|
||||
max_length=255,
|
||||
choices=RoleChoice,
|
||||
default=RoleChoice.USER,
|
||||
)
|
||||
|
||||
USERNAME_FIELD = "phone"
|
||||
objects = UserManager()
|
||||
|
||||
def __str__(self):
|
||||
return self.phone
|
||||
1
core/apps/accounts/seeder/__init__.py
Normal file
1
core/apps/accounts/seeder/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .core import * # noqa
|
||||
10
core/apps/accounts/seeder/core.py
Normal file
10
core/apps/accounts/seeder/core.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Create a new user/superuser
|
||||
"""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
|
||||
class UserSeeder:
|
||||
def run(self):
|
||||
get_user_model().objects.create_superuser("998888112309", "2309")
|
||||
4
core/apps/accounts/serializers/__init__.py
Normal file
4
core/apps/accounts/serializers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .auth import * # noqa
|
||||
from .change_password import * # noqa
|
||||
from .set_password import * # noqa
|
||||
from .user import * # noqa
|
||||
60
core/apps/accounts/serializers/auth.py
Normal file
60
core/apps/accounts/serializers/auth.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from config.env import env
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import exceptions, serializers
|
||||
|
||||
OTP_SIZE = env.int("OTP_SIZE", 4)
|
||||
class LoginSerializer(serializers.Serializer):
|
||||
username = serializers.CharField(max_length=255)
|
||||
password = serializers.CharField(max_length=255)
|
||||
|
||||
|
||||
class RegisterSerializer(serializers.ModelSerializer):
|
||||
phone = serializers.CharField(max_length=255)
|
||||
|
||||
def validate_phone(self, value):
|
||||
user = get_user_model().objects.filter(phone=value, validated_at__isnull=False)
|
||||
if user.exists():
|
||||
raise exceptions.ValidationError(_("Phone number already registered."), code="unique")
|
||||
return value
|
||||
|
||||
class Meta:
|
||||
model = get_user_model()
|
||||
fields = ["first_name", "last_name", "phone", "password"]
|
||||
extra_kwargs = {
|
||||
"first_name": {
|
||||
"required": True,
|
||||
},
|
||||
"last_name": {"required": True},
|
||||
}
|
||||
|
||||
|
||||
class ConfirmSerializer(serializers.Serializer):
|
||||
code = serializers.CharField(max_length=OTP_SIZE, min_length=OTP_SIZE)
|
||||
phone = serializers.CharField(max_length=255)
|
||||
|
||||
|
||||
class ResetPasswordSerializer(serializers.Serializer):
|
||||
phone = serializers.CharField(max_length=255)
|
||||
|
||||
def validate_phone(self, value):
|
||||
user = get_user_model().objects.filter(phone=value)
|
||||
if user.exists():
|
||||
return value
|
||||
|
||||
raise serializers.ValidationError(_("User does not exist"))
|
||||
|
||||
|
||||
class ResetConfirmationSerializer(serializers.Serializer):
|
||||
code = serializers.CharField(min_length=OTP_SIZE, max_length=OTP_SIZE)
|
||||
phone = serializers.CharField(max_length=255)
|
||||
|
||||
def validate_phone(self, value):
|
||||
user = get_user_model().objects.filter(phone=value)
|
||||
if user.exists():
|
||||
return value
|
||||
raise serializers.ValidationError(_("User does not exist"))
|
||||
|
||||
|
||||
class ResendSerializer(serializers.Serializer):
|
||||
phone = serializers.CharField(max_length=255)
|
||||
6
core/apps/accounts/serializers/change_password.py
Normal file
6
core/apps/accounts/serializers/change_password.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class ChangePasswordSerializer(serializers.Serializer):
|
||||
old_password = serializers.CharField(required=True)
|
||||
new_password = serializers.CharField(required=True, min_length=8)
|
||||
6
core/apps/accounts/serializers/set_password.py
Normal file
6
core/apps/accounts/serializers/set_password.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class SetPasswordSerializer(serializers.Serializer):
|
||||
password = serializers.CharField()
|
||||
token = serializers.CharField(max_length=255)
|
||||
23
core/apps/accounts/serializers/user.py
Normal file
23
core/apps/accounts/serializers/user.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
exclude = [
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"password",
|
||||
"groups",
|
||||
"user_permissions"
|
||||
]
|
||||
model = get_user_model()
|
||||
|
||||
|
||||
class UserUpdateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = get_user_model()
|
||||
fields = [
|
||||
"first_name",
|
||||
"last_name"
|
||||
]
|
||||
1
core/apps/accounts/signals/__init__.py
Normal file
1
core/apps/accounts/signals/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .user import * # noqa
|
||||
17
core/apps/accounts/signals/user.py
Normal file
17
core/apps/accounts/signals/user.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
|
||||
@receiver(post_save, sender=get_user_model())
|
||||
def user_signal(sender, created, instance, **kwargs):
|
||||
"""[TODO:summary]
|
||||
|
||||
Args:
|
||||
sender ([TODO:type]): [TODO:description]
|
||||
created ([TODO:type]): [TODO:description]
|
||||
instance ([TODO:type]): [TODO:description]
|
||||
"""
|
||||
if created and instance.username is None:
|
||||
instance.username = "U%(id)s" % {"id": 1000 + instance.id}
|
||||
instance.save()
|
||||
1
core/apps/accounts/tasks/__init__.py
Normal file
1
core/apps/accounts/tasks/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .sms import * # noqa
|
||||
38
core/apps/accounts/tasks/sms.py
Normal file
38
core/apps/accounts/tasks/sms.py
Normal file
@@ -0,0 +1,38 @@
|
||||
#type: ignore
|
||||
"""
|
||||
Base celery tasks
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from importlib import import_module
|
||||
|
||||
from celery import shared_task
|
||||
from config.env import env
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
|
||||
@shared_task
|
||||
def SendConfirm(phone, code):
|
||||
"""Tasdiqlash ko'dini yuborish
|
||||
|
||||
Args:
|
||||
phone (str, int): telefon no'mer
|
||||
code (str, int): tasdiqlash ko'di
|
||||
|
||||
Raises:
|
||||
Exception: [TODO:description]
|
||||
"""
|
||||
try:
|
||||
service = getattr(
|
||||
import_module(os.getenv("OTP_MODULE")), os.getenv("OTP_SERVICE")
|
||||
)()
|
||||
service.send_sms(
|
||||
phone, env.str("OTP_MESSAGE", _("Sizning Tasdiqlash ko'dingiz: %(code)s")) % {"code": code}
|
||||
)
|
||||
logging.info("Sms send: %s-%s" % (phone, code))
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
"Error: {phone}-{code}\n\n{error}".format(phone=phone, code=code, error=e)
|
||||
) # noqa
|
||||
raise Exception
|
||||
0
core/apps/accounts/tests/__init__.py
Normal file
0
core/apps/accounts/tests/__init__.py
Normal file
126
core/apps/accounts/tests/test_auth.py
Normal file
126
core/apps/accounts/tests/test_auth.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from core.apps.accounts.models import ResetToken
|
||||
from core.services import SmsService
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from django_core.models import SmsConfirm
|
||||
from pydantic import BaseModel
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
class TokenModel(BaseModel):
|
||||
access: str
|
||||
refresh: str
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(db):
|
||||
phone = "998999999999"
|
||||
password = "password"
|
||||
user = get_user_model().objects.create_user(phone=phone, first_name="John", last_name="Doe", password=password)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sms_code(test_user):
|
||||
code = "1111"
|
||||
SmsConfirm.objects.create(phone=test_user.phone, code=code)
|
||||
return code
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reg_view(api_client):
|
||||
data = {
|
||||
"phone": "998999999991",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"password": "password",
|
||||
}
|
||||
with patch.object(SmsService, "send_confirm", return_value=True):
|
||||
response = api_client.post(reverse("auth-register"), data=data)
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
assert response.data["data"]["detail"] == f"Sms {data['phone']} raqamiga yuborildi"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_confirm_view(api_client, test_user, sms_code):
|
||||
data = {"phone": test_user.phone, "code": sms_code}
|
||||
response = api_client.post(reverse("auth-confirm"), data=data)
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_invalid_confirm_view(api_client, test_user):
|
||||
data = {"phone": test_user.phone, "code": "1112"}
|
||||
response = api_client.post(reverse("auth-confirm"), data=data)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reset_confirmation_code_view(api_client, test_user, sms_code):
|
||||
data = {"phone": test_user.phone, "code": sms_code}
|
||||
response = api_client.post(reverse("auth-confirm"), data=data)
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
assert "token" in response.data["data"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reset_confirmation_code_view_invalid_code(api_client, test_user):
|
||||
data = {"phone": test_user.phone, "code": "123456"}
|
||||
response = api_client.post(reverse("auth-confirm"), data=data)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reset_set_password_view(api_client, test_user):
|
||||
token = ResetToken.objects.create(user=test_user, token="token")
|
||||
data = {"token": token.token, "password": "new_password"}
|
||||
response = api_client.post(reverse("reset-password-reset-password-set"), data=data)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reset_set_password_view_invalid_token(api_client):
|
||||
token = "test_token"
|
||||
data = {"token": token, "password": "new_password"}
|
||||
with patch.object(get_user_model().objects, "filter", return_value=get_user_model().objects.none()):
|
||||
response = api_client.post(reverse("reset-password-reset-password-set"), data=data)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data["data"]["detail"] == "Invalid token"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_resend_view(api_client, test_user):
|
||||
data = {"phone": test_user.phone}
|
||||
response = api_client.post(reverse("auth-resend"), data=data)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reset_password_view(api_client, test_user):
|
||||
data = {"phone": test_user.phone}
|
||||
response = api_client.post(reverse("reset-password-reset-password"), data=data)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_me_view(api_client, test_user):
|
||||
api_client.force_authenticate(user=test_user)
|
||||
response = api_client.get(reverse("me-me"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_me_update_view(api_client, test_user):
|
||||
api_client.force_authenticate(user=test_user)
|
||||
data = {"first_name": "Updated"}
|
||||
response = api_client.patch(reverse("me-user-update"), data=data)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
77
core/apps/accounts/tests/test_change_password.py
Normal file
77
core/apps/accounts/tests/test_change_password.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import pytest
|
||||
from core.apps.accounts.serializers import ChangePasswordSerializer
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(db):
|
||||
phone = "9981111111"
|
||||
password = "12345670"
|
||||
user = get_user_model().objects.create_user(phone=phone, password=password, email="test@example.com")
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def change_password_url():
|
||||
return reverse("change-password-change-password")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_change_password_success(api_client, test_user, change_password_url):
|
||||
api_client.force_authenticate(user=test_user)
|
||||
data = {
|
||||
"old_password": "12345670",
|
||||
"new_password": "newpassword",
|
||||
}
|
||||
response = api_client.post(change_password_url, data=data, format="json")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["data"]["detail"] == "password changed successfully"
|
||||
|
||||
# Yangi parolni bazadan tekshiramiz
|
||||
test_user.refresh_from_db()
|
||||
assert test_user.check_password("newpassword")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_change_password_invalid_old_password(api_client, test_user, change_password_url):
|
||||
api_client.force_authenticate(user=test_user)
|
||||
data = {
|
||||
"old_password": "wrongpassword",
|
||||
"new_password": "newpassword",
|
||||
}
|
||||
response = api_client.post(change_password_url, data=data, format="json")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data["data"]["detail"] == "invalida password"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_change_password_serializer_validation():
|
||||
valid_data = {
|
||||
"old_password": "12345670",
|
||||
"new_password": "newpassword",
|
||||
}
|
||||
serializer = ChangePasswordSerializer(data=valid_data)
|
||||
assert serializer.is_valid()
|
||||
|
||||
invalid_data = {
|
||||
"old_password": "12345670",
|
||||
"new_password": "123",
|
||||
}
|
||||
serializer = ChangePasswordSerializer(data=invalid_data)
|
||||
assert not serializer.is_valid()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_change_password_view_permissions(api_client, change_password_url):
|
||||
# autentifikatsiyasiz request
|
||||
api_client.force_authenticate(user=None)
|
||||
response = api_client.post(change_password_url, data={}, format="json")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
26
core/apps/accounts/urls.py
Normal file
26
core/apps/accounts/urls.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Accounts app urls
|
||||
"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework_simplejwt import views as jwt_views
|
||||
from .views import RegisterView, ResetPasswordView, MeView, ChangePasswordView
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("auth", RegisterView, basename="auth")
|
||||
router.register("auth", ResetPasswordView, basename="reset-password")
|
||||
router.register("auth", MeView, basename="me")
|
||||
router.register("auth", ChangePasswordView, basename="change-password")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
path("auth/token/", jwt_views.TokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||
path("auth/token/verify/", jwt_views.TokenVerifyView.as_view(), name="token_verify"),
|
||||
path(
|
||||
"auth/token/refresh/",
|
||||
jwt_views.TokenRefreshView.as_view(),
|
||||
name="token_refresh",
|
||||
),
|
||||
]
|
||||
1
core/apps/accounts/views/__init__.py
Normal file
1
core/apps/accounts/views/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .auth import * # noqa
|
||||
209
core/apps/accounts/views/auth.py
Normal file
209
core/apps/accounts/views/auth.py
Normal file
@@ -0,0 +1,209 @@
|
||||
import uuid
|
||||
from typing import Type
|
||||
|
||||
from core.services import UserService, SmsService
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_core import exceptions
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import status, throttling, request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from django_core.mixins import BaseViewSetMixin
|
||||
from rest_framework.decorators import action
|
||||
from ..serializers import (
|
||||
RegisterSerializer,
|
||||
ConfirmSerializer,
|
||||
ResendSerializer,
|
||||
ResetPasswordSerializer,
|
||||
ResetConfirmationSerializer,
|
||||
SetPasswordSerializer,
|
||||
UserSerializer,
|
||||
UserUpdateSerializer,
|
||||
)
|
||||
from rest_framework.permissions import AllowAny
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from drf_spectacular.utils import OpenApiResponse
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from ..serializers import ChangePasswordSerializer
|
||||
|
||||
from .. import models
|
||||
|
||||
|
||||
@extend_schema(tags=["register"])
|
||||
class RegisterView(BaseViewSetMixin, GenericViewSet, UserService):
|
||||
throttle_classes = [throttling.UserRateThrottle]
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get_serializer_class(self):
|
||||
match self.action:
|
||||
case "register":
|
||||
return RegisterSerializer
|
||||
case "confirm":
|
||||
return ConfirmSerializer
|
||||
case "resend":
|
||||
return ResendSerializer
|
||||
case _:
|
||||
return RegisterSerializer
|
||||
|
||||
@action(methods=["POST"], detail=False, url_path="register")
|
||||
def register(self, request):
|
||||
ser = self.get_serializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
data = ser.data
|
||||
phone = data.get("phone")
|
||||
# Create pending user
|
||||
self.create_user(phone, data.get("first_name"), data.get("last_name"), data.get("password"))
|
||||
self.send_confirmation(phone) # Send confirmation code for sms eskiz.uz
|
||||
return Response(
|
||||
{"detail": _("Sms %(phone)s raqamiga yuborildi") % {"phone": phone}},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
|
||||
@extend_schema(summary="Auth confirm.", description="Auth confirm user.")
|
||||
@action(methods=["POST"], detail=False, url_path="confirm")
|
||||
def confirm(self, request):
|
||||
ser = self.get_serializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
data = ser.data
|
||||
phone, code = data.get("phone"), data.get("code")
|
||||
try:
|
||||
if SmsService.check_confirm(phone, code=code):
|
||||
token = self.validate_user(get_user_model().objects.filter(phone=phone).first())
|
||||
return Response(
|
||||
data={
|
||||
"detail": _("Tasdiqlash ko'di qabul qilindi"),
|
||||
"token": token,
|
||||
},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
except exceptions.SmsException as e:
|
||||
raise PermissionDenied(e) # Response exception for APIException
|
||||
except Exception as e:
|
||||
raise PermissionDenied(e) # Api exception for APIException
|
||||
|
||||
@action(methods=["POST"], detail=False, url_path="resend")
|
||||
def resend(self, rq: Type[request.Request]):
|
||||
ser = self.get_serializer(data=rq.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
phone = ser.data.get("phone")
|
||||
self.send_confirmation(phone)
|
||||
return Response({"detail": _("Sms %(phone)s raqamiga yuborildi") % {"phone": phone}})
|
||||
|
||||
|
||||
@extend_schema(tags=["reset-password"])
|
||||
class ResetPasswordView(BaseViewSetMixin, GenericViewSet, UserService):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get_serializer_class(self):
|
||||
match self.action:
|
||||
case "reset_password":
|
||||
return ResetPasswordSerializer
|
||||
case "reset_confirm":
|
||||
return ResetConfirmationSerializer
|
||||
case "reset_password_set":
|
||||
return SetPasswordSerializer
|
||||
case _:
|
||||
return None
|
||||
|
||||
@action(methods=["POST"], detail=False, url_path="reset-password")
|
||||
def reset_password(self, request):
|
||||
ser = self.get_serializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
phone = ser.data.get("phone")
|
||||
self.send_confirmation(phone)
|
||||
return Response({"detail": _("Sms %(phone)s raqamiga yuborildi") % {"phone": phone}})
|
||||
|
||||
@action(methods=["POST"], detail=False, url_path="reset-password-confirm")
|
||||
def reset_confirm(self, request):
|
||||
ser = self.get_serializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
|
||||
data = ser.data
|
||||
code, phone = data.get("code"), data.get("phone")
|
||||
try:
|
||||
SmsService.check_confirm(phone, code)
|
||||
token = models.ResetToken.objects.create(
|
||||
user=get_user_model().objects.filter(phone=phone).first(),
|
||||
token=str(uuid.uuid4()),
|
||||
)
|
||||
return Response(
|
||||
data={
|
||||
"token": token.token,
|
||||
"created_at": token.created_at,
|
||||
"updated_at": token.updated_at,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except exceptions.SmsException as e:
|
||||
raise PermissionDenied(str(e))
|
||||
except Exception as e:
|
||||
raise PermissionDenied(str(e))
|
||||
|
||||
@action(methods=["POST"], detail=False, url_path="reset-password-set")
|
||||
def reset_password_set(self, request):
|
||||
ser = self.get_serializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
data = ser.data
|
||||
token = data.get("token")
|
||||
password = data.get("password")
|
||||
token = models.ResetToken.objects.filter(token=token)
|
||||
if not token.exists():
|
||||
raise PermissionDenied(_("Invalid token"))
|
||||
phone = token.first().user.phone
|
||||
token.delete()
|
||||
self.change_password(phone, password)
|
||||
return Response({"detail": _("password updated")}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@extend_schema(tags=["me"])
|
||||
class MeView(BaseViewSetMixin, GenericViewSet, UserService):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_serializer_class(self):
|
||||
match self.action:
|
||||
case "me":
|
||||
return UserSerializer
|
||||
case "user_update":
|
||||
return UserUpdateSerializer
|
||||
case _:
|
||||
return None
|
||||
|
||||
@action(methods=["GET", "OPTIONS"], detail=False, url_path="me")
|
||||
def me(self, request):
|
||||
return Response(self.get_serializer(request.user).data)
|
||||
|
||||
@action(methods=["PATCH", "PUT"], detail=False, url_path="user-update")
|
||||
def user_update(self, request):
|
||||
ser = self.get_serializer(instance=request.user, data=request.data, partial=True)
|
||||
ser.is_valid(raise_exception=True)
|
||||
ser.save()
|
||||
return Response({"detail": _("Malumotlar yangilandi")})
|
||||
|
||||
|
||||
@extend_schema(tags=["change-password"], description="Parolni o'zgartirish uchun")
|
||||
class ChangePasswordView(BaseViewSetMixin, GenericViewSet):
|
||||
serializer_class = ChangePasswordSerializer
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
@extend_schema(
|
||||
request=serializer_class,
|
||||
responses={200: OpenApiResponse(ChangePasswordSerializer)},
|
||||
summary="Change user password.",
|
||||
description="Change password of the authenticated user.",
|
||||
)
|
||||
@action(methods=["POST"], detail=False, url_path="change-password")
|
||||
def change_password(self, request, *args, **kwargs):
|
||||
user = self.request.user
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
if user.check_password(request.data["old_password"]):
|
||||
user.password = make_password(request.data["new_password"])
|
||||
user.save()
|
||||
return Response(
|
||||
data={"detail": "password changed successfully"},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
raise PermissionDenied(_("invalida password"))
|
||||
0
core/apps/api/__init__.py
Normal file
0
core/apps/api/__init__.py
Normal file
2
core/apps/api/admin/__init__.py
Normal file
2
core/apps/api/admin/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .category import * # noqa
|
||||
from .products import * # noqa
|
||||
32
core/apps/api/admin/category.py
Normal file
32
core/apps/api/admin/category.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from django.contrib import admin
|
||||
from unfold.admin import ModelAdmin, TabularInline
|
||||
from core.apps.api.models import CategoryModel, FilialModel, SubcategoryModel
|
||||
|
||||
|
||||
class SubcategoryInline(TabularInline):
|
||||
model = SubcategoryModel
|
||||
extra = 1
|
||||
|
||||
|
||||
@admin.register(FilialModel)
|
||||
class FilialAdmin(ModelAdmin):
|
||||
list_display = ("id", "name", "created_at")
|
||||
search_fields = ("name",)
|
||||
list_filter = ("created_at",)
|
||||
|
||||
|
||||
@admin.register(CategoryModel)
|
||||
class CategoryAdmin(ModelAdmin):
|
||||
list_display = ("id", "name", "filial", "image", "created_at")
|
||||
list_filter = ("filial", "created_at")
|
||||
search_fields = ("name",)
|
||||
list_select_related = ("filial",)
|
||||
inlines = [SubcategoryInline]
|
||||
|
||||
|
||||
@admin.register(SubcategoryModel)
|
||||
class SubcategoryAdmin(ModelAdmin):
|
||||
list_display = ("id", "name", "category", "created_at")
|
||||
list_filter = ("category", "category__filial", "created_at")
|
||||
search_fields = ("name", "category__name")
|
||||
list_select_related = ("category",)
|
||||
25
core/apps/api/admin/products.py
Normal file
25
core/apps/api/admin/products.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django.contrib import admin
|
||||
from unfold.admin import ModelAdmin, TabularInline
|
||||
from core.apps.api.models import ProductsModel, SubProductModel
|
||||
|
||||
|
||||
class SubProductInline(TabularInline):
|
||||
model = SubProductModel
|
||||
extra = 1
|
||||
|
||||
|
||||
@admin.register(ProductsModel)
|
||||
class ProductsAdmin(ModelAdmin):
|
||||
list_display = ("id", "name", "price", "image", "subcategory", "created_at")
|
||||
list_filter = ("subcategory", "subcategory__category", "subcategory__category__filial", "created_at")
|
||||
search_fields = ("name", "subcategory__name")
|
||||
list_select_related = ("subcategory", "subcategory__category")
|
||||
inlines = [SubProductInline]
|
||||
|
||||
|
||||
@admin.register(SubProductModel)
|
||||
class SubProductAdmin(ModelAdmin):
|
||||
list_display = ("id", "name", "product", "price")
|
||||
list_filter = ("product", "product__subcategory", "created_at")
|
||||
search_fields = ("name", "product__name")
|
||||
list_select_related = ("product",)
|
||||
6
core/apps/api/apps.py
Normal file
6
core/apps/api/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ModuleConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "core.apps.api"
|
||||
0
core/apps/api/enums/__init__.py
Normal file
0
core/apps/api/enums/__init__.py
Normal file
2
core/apps/api/filters/__init__.py
Normal file
2
core/apps/api/filters/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .category import * # noqa
|
||||
from .products import * # noqa
|
||||
21
core/apps/api/filters/category.py
Normal file
21
core/apps/api/filters/category.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from django_filters import rest_framework as filters
|
||||
|
||||
from core.apps.api.models import CategoryModel, SubcategoryModel
|
||||
|
||||
|
||||
class CategoryFilter(filters.FilterSet):
|
||||
class Meta:
|
||||
model = CategoryModel
|
||||
fields = [
|
||||
"name",
|
||||
"filial",
|
||||
]
|
||||
|
||||
|
||||
class SubcategoryFilter(filters.FilterSet):
|
||||
class Meta:
|
||||
model = SubcategoryModel
|
||||
fields = [
|
||||
"name",
|
||||
"category",
|
||||
]
|
||||
17
core/apps/api/filters/products.py
Normal file
17
core/apps/api/filters/products.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django_filters import rest_framework as filters
|
||||
|
||||
from core.apps.api.models import ProductsModel
|
||||
|
||||
|
||||
class ProductsFilter(filters.FilterSet):
|
||||
category = filters.NumberFilter(field_name="subcategory__category_id")
|
||||
filial = filters.NumberFilter(field_name="subcategory__category__filial_id")
|
||||
|
||||
class Meta:
|
||||
model = ProductsModel
|
||||
fields = [
|
||||
"name",
|
||||
"subcategory",
|
||||
"category",
|
||||
"filial",
|
||||
]
|
||||
2
core/apps/api/forms/__init__.py
Normal file
2
core/apps/api/forms/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .category import * # noqa
|
||||
from .products import * # noqa
|
||||
17
core/apps/api/forms/category.py
Normal file
17
core/apps/api/forms/category.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django import forms
|
||||
|
||||
from core.apps.api.models import CategoryModel, SubcategoryModel
|
||||
|
||||
|
||||
class CategoryForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = CategoryModel
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class SubcategoryForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = SubcategoryModel
|
||||
fields = "__all__"
|
||||
10
core/apps/api/forms/products.py
Normal file
10
core/apps/api/forms/products.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django import forms
|
||||
|
||||
from core.apps.api.models import ProductsModel
|
||||
|
||||
|
||||
class ProductsForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = ProductsModel
|
||||
fields = "__all__"
|
||||
79
core/apps/api/migrations/0001_initial.py
Normal file
79
core/apps/api/migrations/0001_initial.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# Generated by Django 5.2.7 on 2026-03-25 14:13
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CategoryModel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='categories/', verbose_name='image')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'CategoryModel',
|
||||
'verbose_name_plural': 'CategoryModels',
|
||||
'db_table': 'category',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubcategoryModel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='api.categorymodel', verbose_name='category')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'SubcategoryModel',
|
||||
'verbose_name_plural': 'SubcategoryModels',
|
||||
'db_table': 'subcategory',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProductsModel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, verbose_name='price')),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='products/', verbose_name='image')),
|
||||
('subcategory', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to='api.subcategorymodel', verbose_name='subcategory')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'ProductsModel',
|
||||
'verbose_name_plural': 'ProductsModels',
|
||||
'db_table': 'products',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubProductModel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
('price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, verbose_name='price')),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='subproducts/', verbose_name='image')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subproducts', to='api.productsmodel', verbose_name='product')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'SubProductModel',
|
||||
'verbose_name_plural': 'SubProductModels',
|
||||
'db_table': 'subproduct',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.7 on 2026-03-25 14:17
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='FilialModel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'FilialModel',
|
||||
'verbose_name_plural': 'FilialModels',
|
||||
'db_table': 'filial',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='categorymodel',
|
||||
name='filial',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='categories', to='api.filialmodel', verbose_name='filial'),
|
||||
),
|
||||
]
|
||||
0
core/apps/api/migrations/__init__.py
Normal file
0
core/apps/api/migrations/__init__.py
Normal file
2
core/apps/api/models/__init__.py
Normal file
2
core/apps/api/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .category import * # noqa
|
||||
from .products import * # noqa
|
||||
67
core/apps/api/models/category.py
Normal file
67
core/apps/api/models/category.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_core.models import AbstractBaseModel
|
||||
from model_bakery import baker
|
||||
|
||||
|
||||
class FilialModel(AbstractBaseModel):
|
||||
name = models.CharField(verbose_name=_("name"), max_length=255)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def _baker(cls):
|
||||
return baker.make(cls)
|
||||
|
||||
class Meta:
|
||||
db_table = "filial"
|
||||
verbose_name = _("FilialModel")
|
||||
verbose_name_plural = _("FilialModels")
|
||||
|
||||
|
||||
class CategoryModel(AbstractBaseModel):
|
||||
filial = models.ForeignKey(
|
||||
FilialModel,
|
||||
verbose_name=_("filial"),
|
||||
related_name="categories",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
name = models.CharField(verbose_name=_("name"), max_length=255)
|
||||
image = models.ImageField(verbose_name=_("image"), upload_to="categories/", null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def _baker(cls):
|
||||
return baker.make(cls)
|
||||
|
||||
class Meta:
|
||||
db_table = "category"
|
||||
verbose_name = _("CategoryModel")
|
||||
verbose_name_plural = _("CategoryModels")
|
||||
|
||||
|
||||
class SubcategoryModel(AbstractBaseModel):
|
||||
category = models.ForeignKey(
|
||||
CategoryModel,
|
||||
verbose_name=_("category"),
|
||||
related_name="subcategories",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
name = models.CharField(verbose_name=_("name"), max_length=255)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def _baker(cls):
|
||||
return baker.make(cls)
|
||||
|
||||
class Meta:
|
||||
db_table = "subcategory"
|
||||
verbose_name = _("SubcategoryModel")
|
||||
verbose_name_plural = _("SubcategoryModels")
|
||||
55
core/apps/api/models/products.py
Normal file
55
core/apps/api/models/products.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_core.models import AbstractBaseModel
|
||||
from model_bakery import baker
|
||||
|
||||
|
||||
from core.apps.api.models.category import SubcategoryModel
|
||||
|
||||
|
||||
class ProductsModel(AbstractBaseModel):
|
||||
name = models.CharField(verbose_name=_("name"), max_length=255)
|
||||
price = models.DecimalField(verbose_name=_("price"), max_digits=10, decimal_places=2, default=0.0)
|
||||
image = models.ImageField(verbose_name=_("image"), upload_to="products/", null=True, blank=True)
|
||||
subcategory = models.ForeignKey(
|
||||
SubcategoryModel,
|
||||
verbose_name=_("subcategory"),
|
||||
related_name="products",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def _baker(cls):
|
||||
return baker.make(cls)
|
||||
|
||||
class Meta:
|
||||
db_table = "products"
|
||||
verbose_name = _("ProductsModel")
|
||||
verbose_name_plural = _("ProductsModels")
|
||||
|
||||
|
||||
class SubProductModel(AbstractBaseModel):
|
||||
product = models.ForeignKey(
|
||||
ProductsModel,
|
||||
verbose_name=_("product"),
|
||||
related_name="subproducts",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
name = models.CharField(verbose_name=_("name"), max_length=255)
|
||||
price = models.DecimalField(verbose_name=_("price"), max_digits=10, decimal_places=2, default=0.0)
|
||||
image = models.ImageField(verbose_name=_("image"), upload_to="subproducts/", null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name} - {self.name}"
|
||||
|
||||
@classmethod
|
||||
def _baker(cls):
|
||||
return baker.make(cls)
|
||||
|
||||
class Meta:
|
||||
db_table = "subproduct"
|
||||
verbose_name = _("SubProductModel")
|
||||
verbose_name_plural = _("SubProductModels")
|
||||
2
core/apps/api/permissions/__init__.py
Normal file
2
core/apps/api/permissions/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .category import * # noqa
|
||||
from .products import * # noqa
|
||||
23
core/apps/api/permissions/category.py
Normal file
23
core/apps/api/permissions/category.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class CategoryPermission(permissions.BasePermission):
|
||||
|
||||
def __init__(self) -> None: ...
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return True
|
||||
|
||||
|
||||
class SubcategoryPermission(permissions.BasePermission):
|
||||
|
||||
def __init__(self) -> None: ...
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return True
|
||||
12
core/apps/api/permissions/products.py
Normal file
12
core/apps/api/permissions/products.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class ProductsPermission(permissions.BasePermission):
|
||||
|
||||
def __init__(self) -> None: ...
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return True
|
||||
2
core/apps/api/serializers/__init__.py
Normal file
2
core/apps/api/serializers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .category import * # noqa
|
||||
from .products import * # noqa
|
||||
3
core/apps/api/serializers/category/__init__.py
Normal file
3
core/apps/api/serializers/category/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .category import * # noqa
|
||||
from .subcategory import * # noqa
|
||||
from .filial import * # noqa
|
||||
40
core/apps/api/serializers/category/category.py
Normal file
40
core/apps/api/serializers/category/category.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from core.apps.api.models import CategoryModel
|
||||
from core.apps.api.serializers.category.subcategory import BaseSubcategorySerializer
|
||||
|
||||
|
||||
class BaseCategorySerializer(serializers.ModelSerializer):
|
||||
subcategories = BaseSubcategorySerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CategoryModel
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"image",
|
||||
"subcategories",
|
||||
]
|
||||
|
||||
|
||||
class ListCategorySerializer(BaseCategorySerializer):
|
||||
class Meta(BaseCategorySerializer.Meta):
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"image",
|
||||
"subcategories",
|
||||
]
|
||||
|
||||
|
||||
class RetrieveCategorySerializer(BaseCategorySerializer):
|
||||
class Meta(BaseCategorySerializer.Meta): ...
|
||||
|
||||
|
||||
class CreateCategorySerializer(BaseCategorySerializer):
|
||||
class Meta(BaseCategorySerializer.Meta):
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"image",
|
||||
]
|
||||
10
core/apps/api/serializers/category/filial.py
Normal file
10
core/apps/api/serializers/category/filial.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from rest_framework import serializers
|
||||
from core.apps.api.models import FilialModel
|
||||
|
||||
class FilialSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = FilialModel
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
]
|
||||
39
core/apps/api/serializers/category/subcategory.py
Normal file
39
core/apps/api/serializers/category/subcategory.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from core.apps.api.models import SubcategoryModel
|
||||
from core.apps.api.serializers.products.products import ListProductsSerializer
|
||||
|
||||
|
||||
class BaseSubcategorySerializer(serializers.ModelSerializer):
|
||||
products = ListProductsSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SubcategoryModel
|
||||
fields = [
|
||||
"id",
|
||||
"category",
|
||||
"name",
|
||||
"products",
|
||||
]
|
||||
|
||||
|
||||
class ListSubcategorySerializer(BaseSubcategorySerializer):
|
||||
class Meta(BaseSubcategorySerializer.Meta):
|
||||
fields = [
|
||||
"id",
|
||||
"category",
|
||||
"name",
|
||||
]
|
||||
|
||||
|
||||
class RetrieveSubcategorySerializer(BaseSubcategorySerializer):
|
||||
class Meta(BaseSubcategorySerializer.Meta): ...
|
||||
|
||||
|
||||
class CreateSubcategorySerializer(BaseSubcategorySerializer):
|
||||
class Meta(BaseSubcategorySerializer.Meta):
|
||||
fields = [
|
||||
"id",
|
||||
"category",
|
||||
"name",
|
||||
]
|
||||
1
core/apps/api/serializers/products/__init__.py
Normal file
1
core/apps/api/serializers/products/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .products import * # noqa
|
||||
55
core/apps/api/serializers/products/products.py
Normal file
55
core/apps/api/serializers/products/products.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from core.apps.api.models import ProductsModel, SubProductModel
|
||||
|
||||
|
||||
class SubProductSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = SubProductModel
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"price",
|
||||
"image",
|
||||
]
|
||||
|
||||
|
||||
class BaseProductsSerializer(serializers.ModelSerializer):
|
||||
subproducts = SubProductSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ProductsModel
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"price",
|
||||
"image",
|
||||
"subcategory",
|
||||
"subproducts",
|
||||
]
|
||||
|
||||
|
||||
class ListProductsSerializer(BaseProductsSerializer):
|
||||
class Meta(BaseProductsSerializer.Meta):
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"price",
|
||||
"image",
|
||||
"subcategory",
|
||||
]
|
||||
|
||||
|
||||
class RetrieveProductsSerializer(BaseProductsSerializer):
|
||||
class Meta(BaseProductsSerializer.Meta): ...
|
||||
|
||||
|
||||
class CreateProductsSerializer(BaseProductsSerializer):
|
||||
class Meta(BaseProductsSerializer.Meta):
|
||||
fields = [
|
||||
"id",
|
||||
"subcategory",
|
||||
"name",
|
||||
"price",
|
||||
"image",
|
||||
]
|
||||
2
core/apps/api/signals/__init__.py
Normal file
2
core/apps/api/signals/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .category import * # noqa
|
||||
from .products import * # noqa
|
||||
12
core/apps/api/signals/category.py
Normal file
12
core/apps/api/signals/category.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from core.apps.api.models import CategoryModel, SubcategoryModel
|
||||
|
||||
|
||||
@receiver(post_save, sender=CategoryModel)
|
||||
def CategorySignal(sender, instance, created, **kwargs): ...
|
||||
|
||||
|
||||
@receiver(post_save, sender=SubcategoryModel)
|
||||
def SubcategorySignal(sender, instance, created, **kwargs): ...
|
||||
8
core/apps/api/signals/products.py
Normal file
8
core/apps/api/signals/products.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from core.apps.api.models import ProductsModel
|
||||
|
||||
|
||||
@receiver(post_save, sender=ProductsModel)
|
||||
def ProductsSignal(sender, instance, created, **kwargs): ...
|
||||
2
core/apps/api/tests/__init__.py
Normal file
2
core/apps/api/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .category import * # noqa
|
||||
from .products import * # noqa
|
||||
2
core/apps/api/tests/category/__init__.py
Normal file
2
core/apps/api/tests/category/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .test_category import * # noqa
|
||||
from .test_subcategory import * # noqa
|
||||
101
core/apps/api/tests/category/test_category.py
Normal file
101
core/apps/api/tests/category/test_category.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.apps.api.models import CategoryModel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def instance(db):
|
||||
return CategoryModel._baker()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client(instance):
|
||||
client = APIClient()
|
||||
##client.force_authenticate(user=instance.user)
|
||||
return client, instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data(api_client):
|
||||
client, instance = api_client
|
||||
return (
|
||||
{
|
||||
"list": reverse("category-list"),
|
||||
"retrieve": reverse("category-detail", kwargs={"pk": instance.pk}),
|
||||
"retrieve-not-found": reverse("category-detail", kwargs={"pk": 1000}),
|
||||
},
|
||||
client,
|
||||
instance,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list(data):
|
||||
urls, client, _ = data
|
||||
response = client.get(urls["list"])
|
||||
data_resp = response.json()
|
||||
assert response.status_code == 200
|
||||
assert data_resp["status"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_retrieve(data):
|
||||
urls, client, _ = data
|
||||
response = client.get(urls["retrieve"])
|
||||
data_resp = response.json()
|
||||
assert response.status_code == 200
|
||||
assert data_resp["status"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_retrieve_not_found(data):
|
||||
urls, client, _ = data
|
||||
response = client.get(urls["retrieve-not-found"])
|
||||
data_resp = response.json()
|
||||
assert response.status_code == 404
|
||||
assert data_resp["status"] is False
|
||||
|
||||
|
||||
# @pytest.mark.django_db
|
||||
# def test_create(data):
|
||||
# urls, client, _ = data
|
||||
# response = client.post(urls["list"], data={"name": "test"})
|
||||
# assert response.json()["status"] is True
|
||||
# assert response.status_code == 201
|
||||
|
||||
|
||||
# @pytest.mark.django_db
|
||||
# def test_update(data):
|
||||
# urls, client, _ = data
|
||||
# response = client.patch(urls["retrieve"], data={"name": "updated"})
|
||||
# assert response.json()["status"] is True
|
||||
# assert response.status_code == 200
|
||||
#
|
||||
# # verify updated value
|
||||
# response = client.get(urls["retrieve"])
|
||||
# assert response.json()["status"] is True
|
||||
# assert response.status_code == 200
|
||||
# assert response.json()["data"]["name"] == "updated"
|
||||
|
||||
|
||||
# @pytest.mark.django_db
|
||||
# def test_partial_update():
|
||||
# urls, client, _ = data
|
||||
# response = client.patch(urls["retrieve"], data={"name": "updated"})
|
||||
# assert response.json()["status"] is True
|
||||
# assert response.status_code == 200
|
||||
#
|
||||
# # verify updated value
|
||||
# response = client.get(urls["retrieve"])
|
||||
# assert response.json()["status"] is True
|
||||
# assert response.status_code == 200
|
||||
# assert response.json()["data"]["name"] == "updated"
|
||||
|
||||
|
||||
# @pytest.mark.django_db
|
||||
# def test_destroy(data):
|
||||
# urls, client, _ = data
|
||||
# response = client.delete(urls["retrieve"])
|
||||
# assert response.status_code == 204
|
||||
101
core/apps/api/tests/category/test_subcategory.py
Normal file
101
core/apps/api/tests/category/test_subcategory.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.apps.api.models import SubcategoryModel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def instance(db):
|
||||
return SubcategoryModel._baker()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client(instance):
|
||||
client = APIClient()
|
||||
##client.force_authenticate(user=instance.user)
|
||||
return client, instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data(api_client):
|
||||
client, instance = api_client
|
||||
return (
|
||||
{
|
||||
"list": reverse("subcategory-list"),
|
||||
"retrieve": reverse("subcategory-detail", kwargs={"pk": instance.pk}),
|
||||
"retrieve-not-found": reverse("subcategory-detail", kwargs={"pk": 1000}),
|
||||
},
|
||||
client,
|
||||
instance,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list(data):
|
||||
urls, client, _ = data
|
||||
response = client.get(urls["list"])
|
||||
data_resp = response.json()
|
||||
assert response.status_code == 200
|
||||
assert data_resp["status"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_retrieve(data):
|
||||
urls, client, _ = data
|
||||
response = client.get(urls["retrieve"])
|
||||
data_resp = response.json()
|
||||
assert response.status_code == 200
|
||||
assert data_resp["status"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_retrieve_not_found(data):
|
||||
urls, client, _ = data
|
||||
response = client.get(urls["retrieve-not-found"])
|
||||
data_resp = response.json()
|
||||
assert response.status_code == 404
|
||||
assert data_resp["status"] is False
|
||||
|
||||
|
||||
# @pytest.mark.django_db
|
||||
# def test_create(data):
|
||||
# urls, client, _ = data
|
||||
# response = client.post(urls["list"], data={"name": "test"})
|
||||
# assert response.json()["status"] is True
|
||||
# assert response.status_code == 201
|
||||
|
||||
|
||||
# @pytest.mark.django_db
|
||||
# def test_update(data):
|
||||
# urls, client, _ = data
|
||||
# response = client.patch(urls["retrieve"], data={"name": "updated"})
|
||||
# assert response.json()["status"] is True
|
||||
# assert response.status_code == 200
|
||||
#
|
||||
# # verify updated value
|
||||
# response = client.get(urls["retrieve"])
|
||||
# assert response.json()["status"] is True
|
||||
# assert response.status_code == 200
|
||||
# assert response.json()["data"]["name"] == "updated"
|
||||
|
||||
|
||||
# @pytest.mark.django_db
|
||||
# def test_partial_update():
|
||||
# urls, client, _ = data
|
||||
# response = client.patch(urls["retrieve"], data={"name": "updated"})
|
||||
# assert response.json()["status"] is True
|
||||
# assert response.status_code == 200
|
||||
#
|
||||
# # verify updated value
|
||||
# response = client.get(urls["retrieve"])
|
||||
# assert response.json()["status"] is True
|
||||
# assert response.status_code == 200
|
||||
# assert response.json()["data"]["name"] == "updated"
|
||||
|
||||
|
||||
# @pytest.mark.django_db
|
||||
# def test_destroy(data):
|
||||
# urls, client, _ = data
|
||||
# response = client.delete(urls["retrieve"])
|
||||
# assert response.status_code == 204
|
||||
1
core/apps/api/tests/products/__init__.py
Normal file
1
core/apps/api/tests/products/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .test_products import * # noqa
|
||||
101
core/apps/api/tests/products/test_products.py
Normal file
101
core/apps/api/tests/products/test_products.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.apps.api.models import ProductsModel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def instance(db):
|
||||
return ProductsModel._baker()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client(instance):
|
||||
client = APIClient()
|
||||
##client.force_authenticate(user=instance.user)
|
||||
return client, instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data(api_client):
|
||||
client, instance = api_client
|
||||
return (
|
||||
{
|
||||
"list": reverse("products-list"),
|
||||
"retrieve": reverse("products-detail", kwargs={"pk": instance.pk}),
|
||||
"retrieve-not-found": reverse("products-detail", kwargs={"pk": 1000}),
|
||||
},
|
||||
client,
|
||||
instance,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list(data):
|
||||
urls, client, _ = data
|
||||
response = client.get(urls["list"])
|
||||
data_resp = response.json()
|
||||
assert response.status_code == 200
|
||||
assert data_resp["status"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_retrieve(data):
|
||||
urls, client, _ = data
|
||||
response = client.get(urls["retrieve"])
|
||||
data_resp = response.json()
|
||||
assert response.status_code == 200
|
||||
assert data_resp["status"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_retrieve_not_found(data):
|
||||
urls, client, _ = data
|
||||
response = client.get(urls["retrieve-not-found"])
|
||||
data_resp = response.json()
|
||||
assert response.status_code == 404
|
||||
assert data_resp["status"] is False
|
||||
|
||||
|
||||
# @pytest.mark.django_db
|
||||
# def test_create(data):
|
||||
# urls, client, _ = data
|
||||
# response = client.post(urls["list"], data={"name": "test"})
|
||||
# assert response.json()["status"] is True
|
||||
# assert response.status_code == 201
|
||||
|
||||
|
||||
# @pytest.mark.django_db
|
||||
# def test_update(data):
|
||||
# urls, client, _ = data
|
||||
# response = client.patch(urls["retrieve"], data={"name": "updated"})
|
||||
# assert response.json()["status"] is True
|
||||
# assert response.status_code == 200
|
||||
#
|
||||
# # verify updated value
|
||||
# response = client.get(urls["retrieve"])
|
||||
# assert response.json()["status"] is True
|
||||
# assert response.status_code == 200
|
||||
# assert response.json()["data"]["name"] == "updated"
|
||||
|
||||
|
||||
# @pytest.mark.django_db
|
||||
# def test_partial_update():
|
||||
# urls, client, _ = data
|
||||
# response = client.patch(urls["retrieve"], data={"name": "updated"})
|
||||
# assert response.json()["status"] is True
|
||||
# assert response.status_code == 200
|
||||
#
|
||||
# # verify updated value
|
||||
# response = client.get(urls["retrieve"])
|
||||
# assert response.json()["status"] is True
|
||||
# assert response.status_code == 200
|
||||
# assert response.json()["data"]["name"] == "updated"
|
||||
|
||||
|
||||
# @pytest.mark.django_db
|
||||
# def test_destroy(data):
|
||||
# urls, client, _ = data
|
||||
# response = client.delete(urls["retrieve"])
|
||||
# assert response.status_code == 204
|
||||
101
core/apps/api/tests/test_hierarchical_api.py
Normal file
101
core/apps/api/tests/test_hierarchical_api.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
from core.apps.api.models import CategoryModel, FilialModel, ProductsModel, SubProductModel, SubcategoryModel
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_hierarchical_filtering(api_client):
|
||||
# 1. Create Filials
|
||||
f_bar = FilialModel.objects.create(name="Bar")
|
||||
f_rest = FilialModel.objects.create(name="Restaurant")
|
||||
|
||||
# 2. Create Data linked to Filials
|
||||
cat1 = CategoryModel.objects.create(name="Electronics", filial=f_bar)
|
||||
cat2 = CategoryModel.objects.create(name="Clothing", filial=f_rest)
|
||||
|
||||
sub1 = SubcategoryModel.objects.create(name="Phones", category=cat1)
|
||||
sub2 = SubcategoryModel.objects.create(name="Laptops", category=cat1)
|
||||
sub3 = SubcategoryModel.objects.create(name="T-Shirts", category=cat2)
|
||||
|
||||
p1 = ProductsModel.objects.create(name="iPhone", subcategory=sub1, price=1000)
|
||||
p2 = ProductsModel.objects.create(name="MacBook", subcategory=sub2, price=2000)
|
||||
p3 = ProductsModel.objects.create(name="Nike Tee", subcategory=sub3, price=50)
|
||||
|
||||
sp1 = SubProductModel.objects.create(product=p1, name="128GB", price=1000)
|
||||
sp2 = SubProductModel.objects.create(product=p1, name="256GB", price=1200)
|
||||
|
||||
# 3. Test Filial Filtering on Products
|
||||
url_prod = reverse("products-list")
|
||||
|
||||
# Filter by Filial Bar (f_bar)
|
||||
response = api_client.get(url_prod, {"filial": f_bar.id})
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]["results"]
|
||||
assert len(data) == 2
|
||||
names = [item["name"] for item in data]
|
||||
assert "iPhone" in names
|
||||
assert "MacBook" in names
|
||||
|
||||
# Filter by Filial Restaurant (f_rest)
|
||||
response = api_client.get(url_prod, {"filial": f_rest.id})
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]["results"]
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "Nike Tee"
|
||||
|
||||
# 4. Test Filial Filtering on Categories
|
||||
url_cat_list = reverse("category-list")
|
||||
response = api_client.get(url_cat_list, {"filial": f_bar.id})
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]["results"]
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "Electronics"
|
||||
|
||||
# 2. Test Product Listing with Category Filter
|
||||
url = reverse("products-list")
|
||||
|
||||
# Filter by Category Electronics (cat1)
|
||||
response = api_client.get(url, {"category": cat1.id})
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
# Should have iPhone and MacBook in results
|
||||
assert len(data["results"]) == 2
|
||||
names = [item["name"] for item in data["results"]]
|
||||
assert "iPhone" in names
|
||||
assert "MacBook" in names
|
||||
|
||||
# Filter by Subcategory Phones (sub1)
|
||||
response = api_client.get(url, {"subcategory": sub1.id})
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
assert len(data["results"]) == 1
|
||||
assert data["results"][0]["name"] == "iPhone"
|
||||
|
||||
# 3. Test Category Detail for Nested Data
|
||||
url_cat = reverse("category-detail", kwargs={"pk": cat1.id})
|
||||
response = api_client.get(url_cat)
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
assert data["name"] == "Electronics"
|
||||
assert len(data["subcategories"]) == 2
|
||||
|
||||
# Check if subcategories have products (if RetrieveSubcategorySerializer includes them)
|
||||
# Wait, in SubcategorySerializer I added 'products' to BaseSubcategorySerializer
|
||||
# and RetrieveCategory uses BaseSubcategorySerializer.
|
||||
phones_sub = next(s for s in data["subcategories"] if s["name"] == "Phones")
|
||||
assert len(phones_sub["products"]) == 1
|
||||
assert phones_sub["products"][0]["name"] == "iPhone"
|
||||
|
||||
# 4. Test Product Detail for Subproducts (variants)
|
||||
url_prod = reverse("products-detail", kwargs={"pk": p1.id})
|
||||
response = api_client.get(url_prod)
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
assert len(data["subproducts"]) == 2
|
||||
variant_names = [v["name"] for v in data["subproducts"]]
|
||||
assert "128GB" in variant_names
|
||||
assert "256GB" in variant_names
|
||||
2
core/apps/api/translation/__init__.py
Normal file
2
core/apps/api/translation/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .category import * # noqa
|
||||
from .products import * # noqa
|
||||
13
core/apps/api/translation/category.py
Normal file
13
core/apps/api/translation/category.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from modeltranslation.translator import TranslationOptions, register
|
||||
|
||||
from core.apps.api.models import CategoryModel, SubcategoryModel
|
||||
|
||||
|
||||
@register(CategoryModel)
|
||||
class CategoryTranslation(TranslationOptions):
|
||||
fields = []
|
||||
|
||||
|
||||
@register(SubcategoryModel)
|
||||
class SubcategoryTranslation(TranslationOptions):
|
||||
fields = []
|
||||
8
core/apps/api/translation/products.py
Normal file
8
core/apps/api/translation/products.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from modeltranslation.translator import TranslationOptions, register
|
||||
|
||||
from core.apps.api.models import ProductsModel
|
||||
|
||||
|
||||
@register(ProductsModel)
|
||||
class ProductsTranslation(TranslationOptions):
|
||||
fields = []
|
||||
12
core/apps/api/urls.py
Normal file
12
core/apps/api/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import CategoryView, FilialView, ProductsView, SubProductView, SubcategoryView
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("filial", FilialView, basename="filial")
|
||||
router.register("subcategory", SubcategoryView, basename="subcategory")
|
||||
router.register("category", CategoryView, basename="category")
|
||||
router.register("products", ProductsView, basename="products")
|
||||
router.register("subproducts", SubProductView, basename="subproducts")
|
||||
urlpatterns = [path("", include(router.urls))]
|
||||
2
core/apps/api/validators/__init__.py
Normal file
2
core/apps/api/validators/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .category import * # noqa
|
||||
from .products import * # noqa
|
||||
15
core/apps/api/validators/category.py
Normal file
15
core/apps/api/validators/category.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class CategoryValidator:
|
||||
def __init__(self): ...
|
||||
|
||||
def __call__(self):
|
||||
return True
|
||||
|
||||
|
||||
class SubcategoryValidator:
|
||||
def __init__(self): ...
|
||||
|
||||
def __call__(self):
|
||||
return True
|
||||
8
core/apps/api/validators/products.py
Normal file
8
core/apps/api/validators/products.py
Normal file
@@ -0,0 +1,8 @@
|
||||
# from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class ProductsValidator:
|
||||
def __init__(self): ...
|
||||
|
||||
def __call__(self):
|
||||
return True
|
||||
2
core/apps/api/views/__init__.py
Normal file
2
core/apps/api/views/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .category import * # noqa
|
||||
from .products import * # noqa
|
||||
75
core/apps/api/views/category.py
Normal file
75
core/apps/api/views/category.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from django_core.mixins import BaseViewSetMixin
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
|
||||
from core.apps.api.filters.category import CategoryFilter, SubcategoryFilter
|
||||
from core.apps.api.models import CategoryModel, FilialModel, SubProductModel, SubcategoryModel
|
||||
from core.apps.api.serializers.category import (
|
||||
CreateCategorySerializer,
|
||||
CreateSubcategorySerializer,
|
||||
FilialSerializer,
|
||||
ListCategorySerializer,
|
||||
ListSubcategorySerializer,
|
||||
RetrieveCategorySerializer,
|
||||
RetrieveSubcategorySerializer,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(tags=["filial"])
|
||||
class FilialView(BaseViewSetMixin, ReadOnlyModelViewSet):
|
||||
queryset = FilialModel.objects.all()
|
||||
serializer_class = FilialSerializer
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
action_permission_classes = {}
|
||||
action_serializer_class = {
|
||||
"list": FilialSerializer,
|
||||
"retrieve": FilialSerializer,
|
||||
"create": FilialSerializer,
|
||||
}
|
||||
from core.apps.api.serializers.products.products import SubProductSerializer
|
||||
|
||||
|
||||
@extend_schema(tags=["category"])
|
||||
class CategoryView(BaseViewSetMixin, ReadOnlyModelViewSet):
|
||||
queryset = CategoryModel.objects.all()
|
||||
serializer_class = ListCategorySerializer
|
||||
permission_classes = [AllowAny]
|
||||
filterset_class = CategoryFilter
|
||||
|
||||
action_permission_classes = {}
|
||||
action_serializer_class = {
|
||||
"list": ListCategorySerializer,
|
||||
"retrieve": RetrieveCategorySerializer,
|
||||
"create": CreateCategorySerializer,
|
||||
}
|
||||
|
||||
|
||||
@extend_schema(tags=["subcategory"])
|
||||
class SubcategoryView(BaseViewSetMixin, ReadOnlyModelViewSet):
|
||||
queryset = SubcategoryModel.objects.all()
|
||||
serializer_class = ListSubcategorySerializer
|
||||
permission_classes = [AllowAny]
|
||||
filterset_class = SubcategoryFilter
|
||||
|
||||
action_permission_classes = {}
|
||||
action_serializer_class = {
|
||||
"list": ListSubcategorySerializer,
|
||||
"retrieve": RetrieveSubcategorySerializer,
|
||||
"create": CreateSubcategorySerializer,
|
||||
}
|
||||
|
||||
|
||||
@extend_schema(tags=["subproduct"])
|
||||
class SubProductView(BaseViewSetMixin, ReadOnlyModelViewSet):
|
||||
queryset = SubProductModel.objects.all()
|
||||
serializer_class = SubProductSerializer
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
action_permission_classes = {}
|
||||
action_serializer_class = {
|
||||
"list": SubProductSerializer,
|
||||
"retrieve": SubProductSerializer,
|
||||
"create": SubProductSerializer,
|
||||
}
|
||||
29
core/apps/api/views/products.py
Normal file
29
core/apps/api/views/products.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from django_core.mixins import BaseViewSetMixin
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
|
||||
from core.apps.api.models import ProductsModel
|
||||
from core.apps.api.serializers.products import (
|
||||
CreateProductsSerializer,
|
||||
ListProductsSerializer,
|
||||
RetrieveProductsSerializer,
|
||||
)
|
||||
|
||||
|
||||
from core.apps.api.filters.products import ProductsFilter
|
||||
|
||||
|
||||
@extend_schema(tags=["products"])
|
||||
class ProductsView(BaseViewSetMixin, ReadOnlyModelViewSet):
|
||||
queryset = ProductsModel.objects.all()
|
||||
serializer_class = ListProductsSerializer
|
||||
permission_classes = [AllowAny]
|
||||
filterset_class = ProductsFilter
|
||||
|
||||
action_permission_classes = {}
|
||||
action_serializer_class = {
|
||||
"list": ListProductsSerializer,
|
||||
"retrieve": RetrieveProductsSerializer,
|
||||
"create": CreateProductsSerializer,
|
||||
}
|
||||
2
core/apps/logs/.gitignore
vendored
Normal file
2
core/apps/logs/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
0
core/apps/shared/__init__.py
Normal file
0
core/apps/shared/__init__.py
Normal file
1
core/apps/shared/admin/__init__.py
Normal file
1
core/apps/shared/admin/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .settings import * # noqa
|
||||
20
core/apps/shared/admin/settings.py
Normal file
20
core/apps/shared/admin/settings.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.contrib import admin
|
||||
from unfold.admin import ModelAdmin, StackedInline
|
||||
from core.apps.shared.models import SettingsModel, OptionsModel
|
||||
from unfold.contrib.forms.widgets import ArrayWidget
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
|
||||
|
||||
class OptionsInline(StackedInline):
|
||||
model = OptionsModel
|
||||
extra = 1
|
||||
formfield_overrides = {
|
||||
ArrayField: {"widget": ArrayWidget},
|
||||
}
|
||||
|
||||
|
||||
@admin.register(SettingsModel)
|
||||
class SettingsAdmin(ModelAdmin):
|
||||
list_display = ["id", "key"]
|
||||
inlines = [OptionsInline]
|
||||
|
||||
6
core/apps/shared/apps.py
Normal file
6
core/apps/shared/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ModuleConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "core.apps.shared"
|
||||
17
core/apps/shared/enums/__init__.py
Normal file
17
core/apps/shared/enums/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class BaseEnum(Enum):
|
||||
|
||||
def choices(self):
|
||||
return [(x.name, x.value) for x in self]
|
||||
|
||||
|
||||
class GenderEnum(BaseEnum):
|
||||
MALE = "male"
|
||||
FEMALE = "female"
|
||||
|
||||
|
||||
class RoleEnum(BaseEnum):
|
||||
ADMIN = "admin"
|
||||
USER = "user"
|
||||
41
core/apps/shared/migrations/0001_initial.py
Normal file
41
core/apps/shared/migrations/0001_initial.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.1.3 on 2025-07-11 15:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SettingsModel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(verbose_name='key')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Settings',
|
||||
'verbose_name_plural': 'Settings',
|
||||
'db_table': 'settings',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OptionsModel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(verbose_name='key')),
|
||||
('value', models.CharField(verbose_name='value')),
|
||||
('settings', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='shared.settingsmodel', verbose_name='settings')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Options',
|
||||
'verbose_name_plural': 'Options',
|
||||
'db_table': 'options',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 5.1.3 on 2025-07-12 05:19
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shared', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='settingsmodel',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='settingsmodel',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='description'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='settingsmodel',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=False, verbose_name='is public'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='settingsmodel',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='optionsmodel',
|
||||
name='key',
|
||||
field=models.CharField(max_length=255, verbose_name='key'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='optionsmodel',
|
||||
name='value',
|
||||
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255, verbose_name='value'), size=None, verbose_name='value'),
|
||||
),
|
||||
]
|
||||
0
core/apps/shared/migrations/__init__.py
Normal file
0
core/apps/shared/migrations/__init__.py
Normal file
1
core/apps/shared/models/__init__.py
Normal file
1
core/apps/shared/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .settings import * # noqa
|
||||
31
core/apps/shared/models/settings.py
Normal file
31
core/apps/shared/models/settings.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_core.models import AbstractBaseModel
|
||||
|
||||
|
||||
class SettingsModel(AbstractBaseModel):
|
||||
key = models.CharField(_("key"))
|
||||
is_public = models.BooleanField(_("is public"), default=False)
|
||||
description = models.TextField(_("description"), blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "settings"
|
||||
verbose_name = _("Settings")
|
||||
verbose_name_plural = _("Settings")
|
||||
|
||||
|
||||
class OptionsModel(models.Model):
|
||||
settings = models.ForeignKey(
|
||||
"SettingsModel", verbose_name=_("settings"), on_delete=models.CASCADE, related_name="options"
|
||||
)
|
||||
key = models.CharField(_("key"), max_length=255)
|
||||
value = ArrayField(
|
||||
models.CharField(_("value"), max_length=255),
|
||||
verbose_name=_("value"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "options"
|
||||
verbose_name = _("Options")
|
||||
verbose_name_plural = _("Options")
|
||||
1
core/apps/shared/serializers/__init__.py
Normal file
1
core/apps/shared/serializers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .settings import * # noqa
|
||||
1
core/apps/shared/serializers/settings/__init__.py
Normal file
1
core/apps/shared/serializers/settings/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .settings import * # noqa
|
||||
7
core/apps/shared/serializers/settings/settings.py
Normal file
7
core/apps/shared/serializers/settings/settings.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class ListLanguageSerializer(serializers.Serializer):
|
||||
code = serializers.CharField(read_only=True)
|
||||
name = serializers.CharField(read_only=True)
|
||||
is_default = serializers.BooleanField(read_only=True, default=False)
|
||||
1
core/apps/shared/tests/__init__.py
Normal file
1
core/apps/shared/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .test_settings import * # noqa
|
||||
20
core/apps/shared/tests/test_settings.py
Normal file
20
core/apps/shared/tests/test_settings.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings_urls():
|
||||
return {
|
||||
"languages": reverse("settings-languages"),
|
||||
}
|
||||
|
||||
|
||||
def test_languages(api_client, settings_urls):
|
||||
response = api_client.get(settings_urls["languages"])
|
||||
assert response.status_code == 200
|
||||
11
core/apps/shared/urls.py
Normal file
11
core/apps/shared/urls.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import SettingsView
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("settings", SettingsView, basename="settings")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
1
core/apps/shared/utils/__init__.py
Normal file
1
core/apps/shared/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .settings import * # noqa
|
||||
17
core/apps/shared/utils/settings.py
Normal file
17
core/apps/shared/utils/settings.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from core.apps.shared.models import OptionsModel
|
||||
from typing import Optional
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def get_config(settings: str, key: str, default=None) -> Optional[str]:
|
||||
config = OptionsModel.objects.filter(settings__key=settings, key=key)
|
||||
if not config.exists():
|
||||
return default
|
||||
return config.first().value
|
||||
|
||||
|
||||
def get_exchange_rate():
|
||||
exchange_rate = get_config("currency", "exchange_rate")
|
||||
if exchange_rate is None:
|
||||
raise Exception(_("USD kursi kiritilmagan iltimos adminga murojat qiling"))
|
||||
return float(exchange_rate[0])
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user