Skip to content

Commit 8c1eb8b

Browse files
committed
Day 5: Register and Login For 3 Role Users
1 parent 5d5b7ed commit 8c1eb8b

File tree

8 files changed

+344
-27
lines changed

8 files changed

+344
-27
lines changed

api/urls.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from django.urls import path
2+
3+
from users.views import (
4+
AdminRegisterView,
5+
LoginView,
6+
TechnicianRegisterView,
7+
UserRegisterView,
8+
)
9+
10+
urlpatterns = [
11+
path("register/user/", UserRegisterView.as_view(), name="user-register"),
12+
path(
13+
"register/technician/",
14+
TechnicianRegisterView.as_view(),
15+
name="technician-register",
16+
),
17+
path("register/admin/", AdminRegisterView.as_view(), name="admin-register"),
18+
path("login/", LoginView.as_view(), name="login"),
19+
]

backend/settings.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@
106106

107107
AUTH_PASSWORD_VALIDATORS = [
108108
{
109-
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
109+
"NAME": "django.contrib.auth.password_validation."
110+
"UserAttributeSimilarityValidator",
110111
},
111112
{
112113
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
@@ -153,7 +154,9 @@
153154

154155
REST_FRAMEWORK = {
155156
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
156-
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",),
157+
"DEFAULT_AUTHENTICATION_CLASSES": (
158+
"rest_framework_simplejwt.authentication.JWTAuthentication",
159+
),
157160
}
158161

159162
AUTH_USER_MODEL = "users.User"
@@ -162,8 +165,8 @@
162165
"TITLE": " RO Water Purifier API",
163166
"DESCRIPTION": "RO Water Purifier is a web-based platform "
164167
"that allows users to browse and purchase RO water purifiers "
165-
"while also providing a seamless way to book certified technicians for installation, "
166-
"maintenance, and service.",
168+
"while also providing a seamless way to book certified technicians "
169+
"for installation, maintenance, and service.",
167170
"VERSION": "1.0.0",
168171
"SERVE_INCLUDE_SCHEMA": False,
169172
}

backend/urls.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,29 @@
1919

2020
from django.contrib import admin
2121
from django.urls import include, path
22-
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
22+
from drf_spectacular.views import (
23+
SpectacularAPIView,
24+
SpectacularRedocView,
25+
SpectacularSwaggerView,
26+
)
2327
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
2428

2529
urlpatterns = [
2630
path("admin/", admin.site.urls),
2731
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
2832
path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
2933
path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
30-
path("api/schema/swagger-ui/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
31-
path("api/schema/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
34+
path(
35+
"api/schema/swagger-ui/",
36+
SpectacularSwaggerView.as_view(url_name="schema"),
37+
name="swagger-ui",
38+
),
39+
path(
40+
"api/schema/redoc/",
41+
SpectacularRedocView.as_view(url_name="schema"),
42+
name="redoc",
43+
),
44+
path("api/", include("api.urls")),
3245
]
3346

3447
urlpatterns += [path("silk/", include("silk.urls", namespace="silk"))]

pyproject.toml

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ dependencies = [
1313
"drf-spectacular>=0.29.0",
1414
"pillow>=12.0.0",
1515
"psycopg2>=2.9.11",
16+
"pytest>=9.0.1",
17+
"pytest-cov>=7.0.0",
18+
"pytest-django>=4.11.1",
1619
"python-decouple>=3.8",
1720
"ruff>=0.14.5",
1821
]
@@ -23,29 +26,48 @@ dev = [
2326
"ruff>=0.14.5",
2427
]
2528

29+
# ===============================
30+
# BLACK (FORMATTER ONLY)
31+
# ===============================
2632
[tool.black]
27-
line-length = 120
33+
line-length = 88
2834
target-version = ["py313"]
2935

36+
# ===============================
37+
# RUFF - LINT + AUTOFIX
38+
# ===============================
3039
[tool.ruff]
31-
line-length = 120
40+
line-length = 88
3241
target-version = "py313"
33-
exclude = ["migrations"]
42+
exclude = ["migrations", ".venv", "env"]
43+
fix = true
3444

3545
[tool.ruff.lint]
3646
select = ["E", "F", "I", "W", "D", "UP", "B", "DJ"]
3747
ignore = [
38-
"D100", "D104",
39-
"D101", "D102", "D103",
40-
"D203", "D212","D105",
41-
48+
"D100",
49+
"D101",
50+
"D102",
51+
"D103",
52+
"D104",
53+
"D105",
54+
"D106",
55+
"D107",
56+
"D203",
57+
"D212",
4258
]
4359

60+
# ===============================
61+
# RUFF FORMAT CONFIG
62+
# ===============================
4463
[tool.ruff.format]
4564
quote-style = "double"
4665
indent-style = "space"
4766
line-ending = "auto"
4867

68+
# ===============================
69+
# RUFF ISORT (IMPORT MANAGEMENT)
70+
# ===============================
4971
[tool.ruff.lint.isort]
5072
combine-as-imports = true
5173
force-single-line = false

pytest.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[pytest]
2+
DJANGO_SETTINGS_MODULE = backend.settings
3+
python_files = test_*.py *_test.py

users/models.py

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ class User(AbstractUser):
99
("admin", "Admin"),
1010
("technician", "Technician"),
1111
]
12+
fullname = models.CharField(max_length=255)
1213
role = models.CharField(max_length=20, choices=ROLE_CHOICES)
1314
address = models.JSONField(blank=True, null=True)
14-
phone_no = models.CharField(max_length=20, blank=True, default="")
15-
mobile_no = models.CharField(max_length=20, blank=True, default="")
15+
mobile = models.CharField(max_length=20, blank=True, default="")
1616

1717

1818
class Tag(models.Model):
@@ -37,23 +37,31 @@ class Profile(models.Model):
3737
years_experience = models.PositiveIntegerField(blank=True, null=True)
3838
months_experience = models.PositiveIntegerField(blank=True, null=True)
3939
tags = models.ManyToManyField(Tag, blank=True) # Expertise categories
40-
available_days = models.JSONField(blank=True, null=True) # Calendar availability data
41-
price_hour = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True)
42-
price_day = models.DecimalField(max_digits=10, decimal_places=2, blank=True, null=True)
40+
available_days = models.JSONField(
41+
blank=True, null=True
42+
) # Calendar availability data
43+
price_hour = models.DecimalField(
44+
max_digits=10, decimal_places=2, blank=True, null=True
45+
)
46+
price_day = models.DecimalField(
47+
max_digits=10, decimal_places=2, blank=True, null=True
48+
)
4349

4450
def __str__(self):
45-
return str(self.user.username)
51+
return str(self.user)
4652

4753

4854
class Review(models.Model):
49-
technician = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name="reviews")
55+
technician = models.ForeignKey(
56+
Profile, on_delete=models.CASCADE, related_name="reviews"
57+
)
5058
reviewer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
5159
rating = models.PositiveSmallIntegerField()
5260
review_text = models.TextField()
5361
created_at = models.DateTimeField(auto_now_add=True)
5462

5563
def __str__(self):
56-
return str(self.technician.user.username) + " - " + str(self.rating)
64+
return str(self.technician) + " - " + str(self.rating)
5765

5866

5967
class Booking(models.Model):
@@ -62,14 +70,20 @@ class Booking(models.Model):
6270
("completed", "Completed"),
6371
("cancelled", "Cancelled"),
6472
]
65-
technician = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name="bookings")
66-
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bookings")
73+
technician = models.ForeignKey(
74+
Profile, on_delete=models.CASCADE, related_name="bookings"
75+
)
76+
user = models.ForeignKey(
77+
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bookings"
78+
)
6779
date_time_start = models.DateTimeField()
6880
date_time_end = models.DateTimeField()
6981
address = models.JSONField(blank=True, null=True)
7082
payment_done = models.BooleanField(default=False)
7183
price = models.DecimalField(max_digits=10, decimal_places=2)
72-
service_status = models.CharField(max_length=20, choices=SERVICE_STATUS, default="pending")
84+
service_status = models.CharField(
85+
max_length=20, choices=SERVICE_STATUS, default="pending"
86+
)
7387

7488
def __str__(self):
75-
return f"Booking {self.id} - {self.technician.user.username} for {self.user.username}"
89+
return f"Booking {self.pk} - {self.technician} for {self.user}"

users/serializers.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import re
2+
3+
from django.contrib.auth import get_user_model
4+
from rest_framework import serializers
5+
6+
User = get_user_model()
7+
8+
9+
class RegisterSerializer(serializers.ModelSerializer):
10+
confirm_password = serializers.CharField(write_only=True)
11+
12+
class Meta:
13+
model = User
14+
fields = [
15+
"fullname",
16+
"username",
17+
"email",
18+
"mobile",
19+
"password",
20+
"confirm_password",
21+
"role",
22+
]
23+
extra_kwargs = {
24+
"password": {"write_only": True},
25+
}
26+
27+
# ===== VALIDATIONS =====
28+
def validate_mobile(self, value):
29+
if not value.isdigit() or len(value) < 10:
30+
raise serializers.ValidationError(
31+
"Mobile number must be at least 10 digits and numeric."
32+
)
33+
if User.objects.filter(mobile=value).exists():
34+
raise serializers.ValidationError("Mobile number already exists.")
35+
return value
36+
37+
def validate_email(self, value):
38+
regex = r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$"
39+
if User.objects.filter(email=value).exists():
40+
raise serializers.ValidationError("Email already exists.")
41+
if not re.match(regex, value):
42+
raise serializers.ValidationError("Enter a valid email address.")
43+
return value
44+
45+
def validate_password(self, value):
46+
if len(value) < 8:
47+
raise serializers.ValidationError(
48+
"Password must be at least 8 characters long."
49+
)
50+
return value
51+
52+
def validate(self, attrs):
53+
if attrs.get("password") != attrs.get("confirm_password"):
54+
raise serializers.ValidationError(
55+
{"confirm_password": "Passwords do not match."}
56+
)
57+
return attrs
58+
59+
def create(self, validated_data):
60+
validated_data.pop("confirm_password")
61+
password = validated_data.pop("password")
62+
user = User(**validated_data)
63+
user.set_password(password)
64+
user.save()
65+
return user
66+
67+
68+
class LoginSerializer(serializers.Serializer):
69+
identifier = serializers.CharField(required=True, allow_blank=False)
70+
password = serializers.CharField(required=True, allow_blank=False, min_length=8)
71+
72+
def validate_identifier(self, value):
73+
if not value or not value.strip():
74+
raise serializers.ValidationError(
75+
"Username, email, or mobile number is required."
76+
)
77+
return value.strip()
78+
79+
def validate_password(self, value):
80+
if not value or not value.strip():
81+
raise serializers.ValidationError("Password is required.")
82+
return value
83+
84+
def create(self, validated_data):
85+
return validated_data # Login does not create objects
86+
87+
def update(self, instance, validated_data):
88+
return instance # Login does not update objects

0 commit comments

Comments
 (0)