This commit is contained in:
2026-05-11 03:27:21 +03:30
parent cf7cbb937c
commit d0e68a1a56
854 changed files with 102985 additions and 76 deletions
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "account"
+25
View File
@@ -0,0 +1,25 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from django.db.models import Q
User = get_user_model()
class MultiFieldBackend(ModelBackend):
"""Authenticate with username, email, or phone_number."""
def authenticate(self, request, username=None, password=None, **kwargs):
if username is None or password is None:
return None
try:
user = User.objects.get(
Q(username=username) | Q(email=username) | Q(phone_number=username)
)
except (User.DoesNotExist, User.MultipleObjectsReturned):
User().set_password(password)
return None
if user.check_password(password) and self.user_can_authenticate(user):
return user
return None
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,24 @@
from django.core.management.base import BaseCommand, CommandError
from account.seeds import ADMIN_USER_DATA
from farm_hub.seeds import seed_admin_farm
class Command(BaseCommand):
help = "Create or update the default admin user through the admin farm seeder."
def handle(self, *args, **options):
try:
farm, created = seed_admin_farm()
except ValueError as exc:
raise CommandError(str(exc)) from exc
action = "created" if created else "updated"
user = farm.owner
self.stdout.write(
self.style.SUCCESS(
f"Admin user {action}: username={user.username}, email={user.email}, "
f"phone_number={user.phone_number}, password={ADMIN_USER_DATA['password']}, "
f"farm_uuid={farm.farm_uuid}"
)
)
@@ -0,0 +1,134 @@
# Generated by Django 5.2.11 on 2026-03-18 14:09
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"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_number",
models.CharField(db_index=True, max_length=32, unique=True),
),
(
"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={
"db_table": "users",
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
]
@@ -0,0 +1,25 @@
# Generated by Django 5.1.15 on 2026-03-23 18:48
import account.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0001_initial'),
]
operations = [
migrations.AlterModelManagers(
name='user',
managers=[
('objects', account.models.CustomUserManager()),
],
),
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='email address'),
),
]
+37
View File
@@ -0,0 +1,37 @@
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as BaseUserManager
from django.db.models import Q
from django.db import models
class CustomUserManager(BaseUserManager):
"""Manager that allows lookup by username, email, or phone_number."""
def get_by_natural_key(self, username):
return self.get(
Q(username=username) | Q(email=username) | Q(phone_number=username)
)
class User(AbstractUser):
phone_number = models.CharField(
max_length=32,
unique=True,
db_index=True,
)
email = models.EmailField(
"email address",
unique=True,
db_index=True,
)
USERNAME_FIELD = "username"
REQUIRED_FIELDS = ["email", "phone_number"]
objects = CustomUserManager()
class Meta:
db_table = "users"
def __str__(self):
return self.username
@@ -0,0 +1,146 @@
{
"info": {
"name": "Account",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"description": "Account API. GET list, GET by uuid (detail), POST add, PATCH update, DELETE delete, PATCH profile. Authenticated user required."
},
"item": [
{
"name": "Update profile",
"request": {
"method": "PATCH",
"header": [
{"key": "Content-Type", "value": "application/json"},
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
],
"body": {
"mode": "raw",
"raw": "{\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"email\": \"\"\n}"
},
"url": "{{baseUrl}}/api/account/profile/",
"description": "Update current user profile (first_name, last_name, email). Returns UpdateProfileResponse."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {\n \"id\": 0,\n \"username\": \"\",\n \"email\": \"\",\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"phone_number\": \"\"\n }\n}"
}
]
},
{
"name": "List accounts",
"request": {
"method": "GET",
"header": [
{"key": "Content-Type", "value": "application/json"},
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
],
"url": "{{baseUrl}}/api/account/",
"description": "Get list of accounts. GET on base route."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {}\n}"
}
]
},
{
"name": "Get account detail (by uuid)",
"request": {
"method": "GET",
"header": [
{"key": "Content-Type", "value": "application/json"},
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
],
"url": "{{baseUrl}}/api/account/{{uuid}}/",
"description": "Get one account by uuid in path."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {}\n}"
}
]
},
{
"name": "Add account",
"request": {
"method": "POST",
"header": [
{"key": "Content-Type", "value": "application/json"},
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
],
"body": {
"mode": "raw",
"raw": "{\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"phones\": []\n}"
},
"url": "{{baseUrl}}/api/account/",
"description": "Add a new account. POST on base route."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"code\": 200,\n \"msg\": \"success\"\n}"
}
]
},
{
"name": "Update account",
"request": {
"method": "PATCH",
"header": [
{"key": "Content-Type", "value": "application/json"},
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
],
"body": {
"mode": "raw",
"raw": "{\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"phones\": []\n}"
},
"url": "{{baseUrl}}/api/account/{{uuid}}/",
"description": "Update account by uuid in path. PATCH."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"code\": 200,\n \"msg\": \"success\"\n}"
}
]
},
{
"name": "Delete account",
"request": {
"method": "DELETE",
"header": [
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
],
"url": "{{baseUrl}}/api/account/{{uuid}}/",
"description": "Delete account by uuid in path."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"code\": 200,\n \"msg\": \"success\"\n}"
}
]
}
],
"variable": [
{"key": "baseUrl", "value": "http://localhost:8000"},
{"key": "token", "value": ""},
{"key": "uuid", "value": "550e8400-e29b-41d4-a716-446655440000"}
]
}
+44
View File
@@ -0,0 +1,44 @@
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Q
ADMIN_USER_DATA = {
"username": "admin",
"email": "admin@example.com",
"phone_number": "0912345678",
"first_name": "admin",
"last_name": "admin",
"password": "admin123456",
}
@transaction.atomic
def seed_admin_user():
user_model = get_user_model()
lookup = (
Q(username=ADMIN_USER_DATA["username"])
| Q(email=ADMIN_USER_DATA["email"])
| Q(phone_number=ADMIN_USER_DATA["phone_number"])
)
matched_users = list(user_model.objects.filter(lookup).order_by("id"))
if len(matched_users) > 1:
raise ValueError(
"Multiple users matched the admin seeder lookup. Resolve duplicates before seeding."
)
created = not matched_users
user = matched_users[0] if matched_users else user_model()
user.username = ADMIN_USER_DATA["username"]
user.email = ADMIN_USER_DATA["email"]
user.phone_number = ADMIN_USER_DATA["phone_number"]
user.first_name = ADMIN_USER_DATA["first_name"]
user.last_name = ADMIN_USER_DATA["last_name"]
user.is_staff = True
user.is_superuser = True
user.is_active = True
user.set_password(ADMIN_USER_DATA["password"])
user.save()
return user, created
+16
View File
@@ -0,0 +1,16 @@
"""
Account API serializers.
UpdateProfile request/response shapes aligned with frontend types.
"""
from rest_framework import serializers
class UpdateProfileSerializer(serializers.Serializer):
"""
Request body for PATCH /api/account/profile/ (UpdateProfilePayload).
"""
first_name = serializers.CharField(max_length=150, required=False, allow_blank=True)
last_name = serializers.CharField(max_length=150, required=False, allow_blank=True)
email = serializers.EmailField(required=False, allow_blank=True)
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from .views import AccountView, ProfileView
urlpatterns = [
path("profile/", ProfileView.as_view(), name="profile-update"),
# path("<uuid:uuid>/", AccountView.as_view(), name="account-detail"),
# path("", AccountView.as_view(), name="account-list"),
]
+160
View File
@@ -0,0 +1,160 @@
"""
Account API module.
CRUD endpoints for user account profile.
"""
from rest_framework import serializers
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, extend_schema_view
from auth.serializers import AuthUserSerializer
from config.swagger import code_response
from .serializers import UpdateProfileSerializer
def _auth_user_to_data(user):
"""Build AuthUser-shaped dict from Django User."""
if user is None or not getattr(user, "pk", None):
return None
return {
"id": user.id,
"username": getattr(user, "username", "") or "",
"email": getattr(user, "email", "") or "",
"first_name": getattr(user, "first_name", "") or "",
"last_name": getattr(user, "last_name", "") or "",
"phone_number": getattr(user, "phone_number", "") or "",
}
@extend_schema_view(
patch=extend_schema(
tags=["Account"],
request=UpdateProfileSerializer,
responses={200: code_response("ProfileUpdateResponse", data=AuthUserSerializer())},
),
)
class ProfileView(APIView):
"""
PATCH /api/account/profile/
UpdateProfilePayload: first_name, last_name, email.
UpdateProfileResponse: code, msg, data (AuthUser).
"""
permission_classes = [IsAuthenticated]
def patch(self, request):
serializer = UpdateProfileSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
user = request.user
for field in ("first_name", "last_name", "email"):
if field in serializer.validated_data:
setattr(user, field, serializer.validated_data[field])
user.save(update_fields=[
f for f in ("first_name", "last_name", "email")
if f in serializer.validated_data
])
data = _auth_user_to_data(user)
if data is None:
data = {
"id": 0,
"username": "",
"email": "",
"first_name": "",
"last_name": "",
"phone_number": "",
}
return Response(
{"code": 200, "msg": "success", "data": data},
status=status.HTTP_200_OK,
)
@extend_schema_view(
get=extend_schema(
tags=["Account"],
responses={200: code_response("AccountGetResponse", data=serializers.JSONField())},
),
post=extend_schema(
tags=["Account"],
request=OpenApiTypes.OBJECT,
responses={200: code_response("AccountCreateResponse")},
),
patch=extend_schema(
tags=["Account"],
request=OpenApiTypes.OBJECT,
responses={200: code_response("AccountUpdateResponse")},
),
delete=extend_schema(
tags=["Account"],
responses={200: code_response("AccountDeleteResponse")},
),
)
class AccountView(APIView):
"""
Account CRUD endpoints. Dispatch by HTTP method and path (uuid for detail/update/delete).
No processing, validation, or transformation is applied to any input.
All endpoints return HTTP 200 only. Response format: {"code": 200, "msg": "success"} or {"code": 200, "msg": "success", "data": {}}.
Routes:
- GET "" → List: returns status "success", data {}.
- GET "<uuid>/" → Detail: uuid (path). Returns status "success", data {}.
- POST "" → Create: body/query may contain first_name, last_name, phones; not used. Returns status "success". No data field.
- PATCH "<uuid>/" → Update: uuid (path), body/query may contain first_name, last_name, phones; not used. Returns status "success". No data field.
- DELETE "<uuid>/" → Delete: uuid (path). Returns status "success". No data field.
"""
permission_classes = [IsAuthenticated]
def get(self, request, *args, **kwargs):
"""
List or detail account.
List (GET on base URL):
- Input parameters: none required. Query params if sent are not processed.
- Response: {"code": 200, "msg": "success", "data": {}}.
- No processing or validation is performed on inputs.
Detail (GET on <uuid>/):
- Input parameters: uuid (path, UUID). Description: identifier for the account resource.
- Response: {"code": 200, "msg": "success", "data": {}}.
- No processing or validation is performed on inputs.
"""
return Response({"code": 200, "msg": "success", "data": {}}, status=status.HTTP_200_OK)
def post(self, request, *args, **kwargs):
"""
Create account.
Input parameters (body, JSON): first_name (string), last_name (string), phones (array of strings).
Description: intended for user first name, last name, and phone numbers. Not processed or validated.
Response: {"code": 200, "msg": "success"}. No data field.
No processing or validation is performed on inputs.
"""
return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK)
def patch(self, request, *args, **kwargs):
"""
Update account.
Input parameters: uuid (path, UUID), body (JSON) may contain first_name, last_name, phones.
Description: identifier in path; body fields intended for updated profile. Not processed or validated.
Response: {"code": 200, "msg": "success"}. No data field.
No processing or validation is performed on inputs.
"""
return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK)
def delete(self, request, *args, **kwargs):
"""
Delete account.
Input parameters: uuid (path, UUID). Description: identifier for the account resource to delete.
Response: {"code": 200, "msg": "success"}. No data field.
No processing or validation is performed on inputs.
"""
return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK)