diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index af0d53c..950dabf 100644 Binary files a/core/__pycache__/forms.cpython-311.pyc and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index be16afb..e198ee6 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index 4be52ff..fddd747 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index ea8c7f4..be364c3 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/forms.py b/core/forms.py index e14a845..0e82b0d 100644 --- a/core/forms.py +++ b/core/forms.py @@ -1,5 +1,5 @@ from django import forms -from .models import Truck, Shipment, Bid, Profile, Country +from .models import Truck, Shipment, Bid, Profile, Country, OTPCode from django.utils.translation import gettext_lazy as _ from django.contrib.auth.forms import UserCreationForm from django.contrib.auth.models import User @@ -9,7 +9,7 @@ class UserRegistrationForm(UserCreationForm): confirm_email = forms.EmailField(required=True, widget=forms.EmailInput(attrs={'class': 'form-control'}), label=_("Confirm Email")) role = forms.ChoiceField(choices=Profile.ROLE_CHOICES, widget=forms.Select(attrs={'class': 'form-select'})) country_code = forms.ChoiceField(choices=[], widget=forms.Select(attrs={'class': 'form-select'})) - phone_number = forms.CharField(max_length=20, required=False, widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '123456789'})) + phone_number = forms.CharField(max_length=20, required=True, widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '123456789'})) class Meta(UserCreationForm.Meta): model = User @@ -33,6 +33,20 @@ class UserRegistrationForm(UserCreationForm): if not isinstance(field.widget, (forms.CheckboxInput, forms.Select)): field.widget.attrs.update({'class': 'form-control'}) + def clean_email(self): + email = self.cleaned_data.get('email') + if User.objects.filter(email=email).exists(): + raise forms.ValidationError(_("This email is already in use.")) + return email + + def clean_phone_number(self): + phone_number = self.cleaned_data.get('phone_number') + country_code = self.cleaned_data.get('country_code') + # We check uniqueness of phone_number in Profile + if Profile.objects.filter(phone_number=phone_number).exists(): + raise forms.ValidationError(_("This phone number is already in use.")) + return phone_number + def clean(self): cleaned_data = super().clean() email = cleaned_data.get("email") @@ -43,6 +57,9 @@ class UserRegistrationForm(UserCreationForm): return cleaned_data +class OTPVerifyForm(forms.Form): + otp_code = forms.CharField(max_length=6, widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '123456'})) + class TruckForm(forms.ModelForm): class Meta: model = Truck @@ -98,4 +115,4 @@ class BidForm(forms.ModelForm): # Only allow bidding with approved trucks self.fields['truck'].queryset = Truck.objects.filter(owner=user, is_approved=True) if not self.fields['truck'].queryset.exists(): - self.fields['truck'].help_text = _("You must have an approved truck to place a bid.") + self.fields['truck'].help_text = _("You must have an approved truck to place a bid.") \ No newline at end of file diff --git a/core/migrations/0009_otpcode_alter_profile_phone_number.py b/core/migrations/0009_otpcode_alter_profile_phone_number.py new file mode 100644 index 0000000..3a031e9 --- /dev/null +++ b/core/migrations/0009_otpcode_alter_profile_phone_number.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.7 on 2026-01-23 12:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_country'), + ] + + operations = [ + migrations.CreateModel( + name='OTPCode', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('phone_number', models.CharField(max_length=20)), + ('code', models.CharField(max_length=6)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('is_used', models.BooleanField(default=False)), + ], + ), + migrations.AlterField( + model_name='profile', + name='phone_number', + field=models.CharField(max_length=20, null=True, unique=True), + ), + ] diff --git a/core/migrations/__pycache__/0009_otpcode_alter_profile_phone_number.cpython-311.pyc b/core/migrations/__pycache__/0009_otpcode_alter_profile_phone_number.cpython-311.pyc new file mode 100644 index 0000000..b29a475 Binary files /dev/null and b/core/migrations/__pycache__/0009_otpcode_alter_profile_phone_number.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index e063def..4f11329 100644 --- a/core/models.py +++ b/core/models.py @@ -4,6 +4,9 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ from django.utils.translation import get_language +from django.utils import timezone +import random +import string class Country(models.Model): name = models.CharField(_('Country Name'), max_length=100) @@ -26,7 +29,7 @@ class Profile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='SHIPPER') country_code = models.CharField(max_length=5, blank=True, default="966") - phone_number = models.CharField(max_length=20, blank=True) + phone_number = models.CharField(max_length=20, unique=True, null=True) # Changed to unique and nullable for migration safety @property def full_phone_number(self): @@ -40,6 +43,21 @@ class Profile(models.Model): def __str__(self): return f"{self.user.username} - {self.role}" +class OTPCode(models.Model): + phone_number = models.CharField(max_length=20) + code = models.CharField(max_length=6) + created_at = models.DateTimeField(auto_now_add=True) + is_used = models.BooleanField(default=False) + + def is_valid(self): + # Valid for 10 minutes + return not self.is_used and (timezone.now() - self.created_at).total_seconds() < 600 + + @staticmethod + def generate_code(phone_number): + code = ''.join(random.choices(string.digits, k=6)) + return OTPCode.objects.create(phone_number=phone_number, code=code) + class Truck(models.Model): owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='trucks') @@ -166,4 +184,4 @@ def save_user_profile(sender, instance, **kwargs): if hasattr(instance, 'profile'): instance.profile.save() else: - Profile.objects.create(user=instance) + Profile.objects.create(user=instance) \ No newline at end of file diff --git a/core/templates/registration/verify_otp.html b/core/templates/registration/verify_otp.html new file mode 100644 index 0000000..0b0fb5f --- /dev/null +++ b/core/templates/registration/verify_otp.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} +
+
+
+
+
+

{% trans "Verify Your Account" %}

+

+ {% if purpose == 'registration' %} + {% trans "We have sent a verification code to your WhatsApp number. Please enter it below to complete your registration." %} + {% else %} + {% trans "We have sent a verification code to your WhatsApp number. Please enter it below to log in." %} + {% endif %} +

+
+ {% csrf_token %} + {% for field in form %} +
+ + {{ field }} + {% if field.errors %} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} + {% endif %} +
+ {% endfor %} +
+ +
+
+
+ {% trans "Didn't receive the code?" %} {% trans "Try again" %} +
+
+
+
+
+
+{% endblock %} diff --git a/core/urls.py b/core/urls.py index 4a39602..597f03a 100644 --- a/core/urls.py +++ b/core/urls.py @@ -5,7 +5,9 @@ from . import views urlpatterns = [ path("", views.home, name="home"), path("register/", views.register, name="register"), - path("login/", auth_views.LoginView.as_view(), name="login"), + path("verify-otp-registration/", views.verify_otp_registration, name="verify_otp_registration"), + path("login/", views.custom_login, name="login"), + path("verify-otp-login/", views.verify_otp_login, name="verify_otp_login"), path("logout/", auth_views.LogoutView.as_view(), name="logout"), path("dashboard/", views.dashboard, name="dashboard"), path("truck/register/", views.truck_register, name="truck_register"), @@ -17,4 +19,4 @@ urlpatterns = [ path("shipment//", views.shipment_detail, name="shipment_detail"), path("shipment//bid/", views.place_bid, name="place_bid"), path("bid//accept/", views.accept_bid, name="accept_bid"), -] \ No newline at end of file +] diff --git a/core/views.py b/core/views.py index 55b07ac..1bda90a 100644 --- a/core/views.py +++ b/core/views.py @@ -1,14 +1,15 @@ from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth.decorators import login_required -from django.contrib.auth import login, authenticate +from django.contrib.auth import login, authenticate, logout from django.utils import timezone -from .models import Profile, Truck, Shipment, Bid, Message -from .forms import TruckForm, ShipmentForm, BidForm, UserRegistrationForm +from .models import Profile, Truck, Shipment, Bid, Message, OTPCode +from .forms import TruckForm, ShipmentForm, BidForm, UserRegistrationForm, OTPVerifyForm from django.contrib import messages from django.utils.translation import gettext as _ from django.db.models import Q from django.contrib.auth.models import User from .whatsapp import send_whatsapp_message +from django.contrib.auth.forms import AuthenticationForm def home(request): """Render the landing screen for MASAR CARGO.""" @@ -21,21 +22,131 @@ def register(request): if request.method == 'POST': form = UserRegistrationForm(request.POST) if form.is_valid(): - user = form.save() - profile = user.profile - profile.role = form.cleaned_data.get('role') - profile.phone_number = form.cleaned_data.get('phone_number') - profile.country_code = form.cleaned_data.get('country_code') - profile.save() - login(request, user) - messages.success(request, _("Registration successful. Welcome!")) - return redirect('dashboard') + # Store data in session to be used after OTP verification + registration_data = { + 'username': form.cleaned_data['username'], + 'email': form.cleaned_data['email'], + 'password': form.data['password1'], # We need raw password to create user later + 'role': form.cleaned_data['role'], + 'phone_number': form.cleaned_data['phone_number'], + 'country_code': form.cleaned_data['country_code'], + } + request.session['registration_data'] = registration_data + + # Send OTP + full_phone = f"{registration_data['country_code']}{registration_data['phone_number']}" + otp = OTPCode.generate_code(full_phone) + msg = f"Your verification code for MASAR CARGO is: {otp.code}" + if send_whatsapp_message(full_phone, msg): + messages.info(request, _("A verification code has been sent to your WhatsApp.")) + return redirect('verify_otp_registration') + else: + messages.error(request, _("Failed to send verification code. Please check your phone number.")) else: messages.error(request, _("Please correct the errors below.")) else: form = UserRegistrationForm() return render(request, 'registration/register.html', {'form': form}) +def verify_otp_registration(request): + registration_data = request.session.get('registration_data') + if not registration_data: + return redirect('register') + + if request.method == 'POST': + form = OTPVerifyForm(request.POST) + if form.is_valid(): + code = form.cleaned_data['otp_code'] + full_phone = f"{registration_data['country_code']}{registration_data['phone_number']}" + otp_record = OTPCode.objects.filter(phone_number=full_phone, code=code, is_used=False).last() + + if otp_record and otp_record.is_valid(): + otp_record.is_used = True + otp_record.save() + + # Create user + user = User.objects.create_user( + username=registration_data['username'], + email=registration_data['email'], + password=registration_data['password'] + ) + profile = user.profile + profile.role = registration_data['role'] + profile.phone_number = registration_data['phone_number'] + profile.country_code = registration_data['country_code'] + profile.save() + + login(request, user) + del request.session['registration_data'] + messages.success(request, _("Registration successful. Welcome!")) + return redirect('dashboard') + else: + messages.error(request, _("Invalid or expired verification code.")) + else: + form = OTPVerifyForm() + + return render(request, 'registration/verify_otp.html', {'form': form, 'purpose': 'registration'}) + +def custom_login(request): + if request.method == 'POST': + form = AuthenticationForm(request, data=request.POST) + if form.is_valid(): + user = form.get_user() + profile = user.profile + if not profile.phone_number: + messages.error(request, _("Your account does not have a phone number. Please contact admin.")) + return redirect('login') + + # Store user ID in session temporarily + request.session['pre_otp_user_id'] = user.id + + # Send OTP + full_phone = profile.full_phone_number + otp = OTPCode.generate_code(full_phone) + msg = f"Your login verification code for MASAR CARGO is: {otp.code}" + if send_whatsapp_message(full_phone, msg): + messages.info(request, _("A verification code has been sent to your WhatsApp.")) + return redirect('verify_otp_login') + else: + # If WhatsApp fails, maybe allow login but warn? Or strictly enforce? + # For now, strictly enforce + messages.error(request, _("Failed to send verification code. Please check your connection.")) + else: + messages.error(request, _("Invalid username or password.")) + else: + form = AuthenticationForm() + return render(request, 'registration/login.html', {'form': form}) + +def verify_otp_login(request): + user_id = request.session.get('pre_otp_user_id') + if not user_id: + return redirect('login') + + user = get_object_or_404(User, id=user_id) + profile = user.profile + + if request.method == 'POST': + form = OTPVerifyForm(request.POST) + if form.is_valid(): + code = form.cleaned_data['otp_code'] + full_phone = profile.full_phone_number + otp_record = OTPCode.objects.filter(phone_number=full_phone, code=code, is_used=False).last() + + if otp_record and otp_record.is_valid(): + otp_record.is_used = True + otp_record.save() + + login(request, user) + del request.session['pre_otp_user_id'] + messages.success(request, _("Logged in successfully!")) + return redirect('dashboard') + else: + messages.error(request, _("Invalid or expired verification code.")) + else: + form = OTPVerifyForm() + + return render(request, 'registration/verify_otp.html', {'form': form, 'purpose': 'login'}) + @login_required def dashboard(request): profile, created = Profile.objects.get_or_create(user=request.user) @@ -234,4 +345,4 @@ def accept_bid(request, bid_id): send_whatsapp_message(owner_phone, msg) messages.success(request, _("Bid accepted! Shipment is now in progress.")) - return redirect('shipment_detail', shipment_id=bid.shipment.id) + return redirect('shipment_detail', shipment_id=bid.shipment.id) \ No newline at end of file