diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index e0ce395..cedff70 100644 Binary files a/core/__pycache__/forms.cpython-311.pyc and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/mail.cpython-311.pyc b/core/__pycache__/mail.cpython-311.pyc new file mode 100644 index 0000000..cf89663 Binary files /dev/null and b/core/__pycache__/mail.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index 673917c..8e53bb3 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index d529511..1c83655 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 df3ac14..ab9441c 100644 --- a/core/forms.py +++ b/core/forms.py @@ -1,7 +1,7 @@ from django import forms from .models import Truck, Shipment, Bid, Profile, Country, OTPCode, City, TruckType, AppSetting, ContactMessage from django.utils.translation import gettext_lazy as _ -from django.contrib.auth.forms import UserCreationForm +from django.contrib.auth.forms import UserCreationForm, AuthenticationForm from django.contrib.auth.models import User class UserRegistrationForm(UserCreationForm): @@ -11,6 +11,7 @@ class UserRegistrationForm(UserCreationForm): country_code = forms.ChoiceField(choices=[], widget=forms.Select(attrs={'class': 'form-select'})) phone_number = forms.CharField(max_length=20, required=True, widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '123456789'})) subscription_plan = forms.ChoiceField(choices=[('MONTHLY', _('Monthly')), ('ANNUAL', _('Annual'))], required=False, widget=forms.Select(attrs={'class': 'form-select'}), label=_('Subscription Plan')) + otp_method = forms.ChoiceField(choices=[("whatsapp", _("WhatsApp")), ("email", _("Email"))], initial="whatsapp", widget=forms.RadioSelect, label=_("Receive verification code via")) accept_terms = forms.BooleanField(required=True, widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}), label=_('I have read and agree to the Terms and Conditions and Privacy Policy')) class Meta(UserCreationForm.Meta): @@ -199,3 +200,18 @@ class ContactForm(forms.ModelForm): 'subject': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Subject')}), 'message': forms.Textarea(attrs={'class': 'form-control', 'rows': 5, 'placeholder': _('Your Message')}), } + +class CustomLoginForm(AuthenticationForm): + otp_method = forms.ChoiceField( + choices=[("whatsapp", _("WhatsApp")), ("email", _("Email"))], + initial="whatsapp", + widget=forms.RadioSelect, + label=_("Receive verification code via") + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + if not isinstance(field.widget, (forms.RadioSelect, forms.CheckboxInput)): + field.widget.attrs.update({"class": "form-control"}) + diff --git a/core/mail.py b/core/mail.py new file mode 100644 index 0000000..18ad8d1 --- /dev/null +++ b/core/mail.py @@ -0,0 +1,40 @@ +import logging +from django.core.mail import send_mail +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + +logger = logging.getLogger(__name__) + +def send_otp_email(email, code): + """ + Sends an OTP code via email. + """ + subject = _("Your verification code for MASAR CARGO") + message = _("Your verification code for MASAR CARGO is: %(code)s") % {"code": code} + from_email = settings.DEFAULT_FROM_EMAIL + recipient_list = [email] + + try: + send_mail(subject, message, from_email, recipient_list) + logger.info(f"OTP email sent to {email}") + return True + except Exception as e: + logger.exception(f"Exception while sending OTP email: {str(e)}") + return False + +def send_contact_message(name, email, message): + """ + Sends a contact message to the admin. + """ + subject = _("New contact message from %(name)s") % {"name": name} + full_message = f"Name: {name}\nEmail: {email}\n\nMessage:\n{message}" + from_email = settings.DEFAULT_FROM_EMAIL + recipient_list = settings.CONTACT_EMAIL_TO + + try: + send_mail(subject, full_message, from_email, recipient_list) + logger.info(f"Contact message from {email} sent to admins") + return True + except Exception as e: + logger.exception(f"Exception while sending contact message: {str(e)}") + return False diff --git a/core/migrations/0027_otpcode_email_alter_otpcode_phone_number.py b/core/migrations/0027_otpcode_email_alter_otpcode_phone_number.py new file mode 100644 index 0000000..6a6c3a1 --- /dev/null +++ b/core/migrations/0027_otpcode_email_alter_otpcode_phone_number.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2026-01-25 02:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0026_remove_banner_admin_phone_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='otpcode', + name='email', + field=models.EmailField(blank=True, max_length=254, null=True), + ), + migrations.AlterField( + model_name='otpcode', + name='phone_number', + field=models.CharField(blank=True, max_length=20, null=True), + ), + ] diff --git a/core/migrations/__pycache__/0027_otpcode_email_alter_otpcode_phone_number.cpython-311.pyc b/core/migrations/__pycache__/0027_otpcode_email_alter_otpcode_phone_number.cpython-311.pyc new file mode 100644 index 0000000..356f778 Binary files /dev/null and b/core/migrations/__pycache__/0027_otpcode_email_alter_otpcode_phone_number.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 1eb5552..b6f951e 100644 --- a/core/models.py +++ b/core/models.py @@ -93,7 +93,8 @@ class Profile(models.Model): return f"{self.user.username} - {self.role}" class OTPCode(models.Model): - phone_number = models.CharField(max_length=20) + phone_number = models.CharField(max_length=20, null=True, blank=True) + email = models.EmailField(null=True, blank=True) code = models.CharField(max_length=6) created_at = models.DateTimeField(auto_now_add=True) is_used = models.BooleanField(default=False) @@ -103,9 +104,9 @@ class OTPCode(models.Model): return not self.is_used and (timezone.now() - self.created_at).total_seconds() < 600 @staticmethod - def generate_code(phone_number): + def generate_code(phone_number=None, email=None): code = ''.join(random.choices(string.digits, k=6)) - return OTPCode.objects.create(phone_number=phone_number, code=code) + return OTPCode.objects.create(phone_number=phone_number, email=email, code=code) class Truck(models.Model): owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='trucks') diff --git a/core/templates/registration/login.html b/core/templates/registration/login.html index b0edb65..6444cba 100644 --- a/core/templates/registration/login.html +++ b/core/templates/registration/login.html @@ -18,13 +18,41 @@ {% csrf_token %}
- + {{ form.username }} + {% if form.username.errors %} + {% for error in form.username.errors %} +
{{ error }}
+ {% endfor %} + {% endif %}
- + {{ form.password }} + {% if form.password.errors %} + {% for error in form.password.errors %} +
{{ error }}
+ {% endfor %} + {% endif %}
- + +
+ +
+ {% for radio in form.otp_method %} + + + {% endfor %} +
+
+ +

{% trans "Don't have an account?" %} {% trans "Register" %}

diff --git a/core/templates/registration/register.html b/core/templates/registration/register.html index e9596cf..c384426 100644 --- a/core/templates/registration/register.html +++ b/core/templates/registration/register.html @@ -47,6 +47,28 @@
{% elif field.name == 'phone_number' %} {# Handled above #} + {% elif field.name == 'otp_method' %} +
+ +
+ {% for radio in field %} + + + {% endfor %} +
+ {% if field.errors %} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} + {% endif %} +
{% elif field.name == 'subscription_plan' %} {% if subscription_enabled %}
diff --git a/core/templates/registration/verify_otp.html b/core/templates/registration/verify_otp.html index 5e7e55b..f7ddf66 100644 --- a/core/templates/registration/verify_otp.html +++ b/core/templates/registration/verify_otp.html @@ -14,10 +14,18 @@

{% 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." %} + {% if otp_method == 'email' %} + {% if purpose == 'registration' %} + {% trans "We have sent a verification code to your email address. Please enter it below to complete your registration." %} + {% else %} + {% trans "We have sent a verification code to your email address. Please enter it below to log in." %} + {% endif %} {% else %} - {% trans "We have sent a verification code to your WhatsApp number. Please enter it below to log in." %} + {% 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 %} {% endif %}

diff --git a/core/views.py b/core/views.py index 6360e66..d1bd06d 100644 --- a/core/views.py +++ b/core/views.py @@ -10,6 +10,7 @@ from .models import ( AppSetting, Banner, HomeSection, Transaction, ContactMessage, Testimonial, WhatsAppConfig ) from .forms import ( + CustomLoginForm, TruckForm, ShipmentForm, BidForm, UserRegistrationForm, OTPVerifyForm, ShipperOfferForm, RenewSubscriptionForm, AppSettingForm, ContactForm @@ -19,6 +20,7 @@ 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 .mail import send_otp_email, send_contact_message from django.contrib.auth.forms import AuthenticationForm from django.core.mail import send_mail from django.conf import settings @@ -70,23 +72,35 @@ def register(request): registration_data = { 'username': form.cleaned_data['username'], 'email': form.cleaned_data['email'], - 'password': form.data['password1'], # We need raw password to create user later + 'password': form.data['password1'], 'role': form.cleaned_data['role'], 'phone_number': form.cleaned_data['phone_number'], 'country_code': form.cleaned_data['country_code'], 'subscription_plan': form.cleaned_data.get('subscription_plan', 'NONE'), + 'otp_method': form.cleaned_data.get('otp_method', 'whatsapp'), } 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 = _("Your verification code for MASAR CARGO is: %(code)s") % {"code": 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') + email = registration_data['email'] + otp_method = registration_data.get('otp_method', 'whatsapp') + + if otp_method == 'email': + otp = OTPCode.generate_code(email=email) + if send_otp_email(email, otp.code): + messages.info(request, _("A verification code has been sent to your email.")) + return redirect('verify_otp_registration') + else: + messages.error(request, _("Failed to send verification code to your email.")) else: - messages.error(request, _("Failed to send verification code. Please check your phone number.")) + otp = OTPCode.generate_code(phone_number=full_phone) + msg = _("Your verification code for MASAR CARGO is: %(code)s") % {"code": 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: @@ -108,7 +122,13 @@ def verify_otp_registration(request): 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() + email = registration_data['email'] + otp_method = registration_data.get('otp_method', 'whatsapp') + + if otp_method == 'email': + otp_record = OTPCode.objects.filter(email=email, code=code, is_used=False).last() + else: + 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 @@ -141,40 +161,52 @@ def verify_otp_registration(request): else: form = OTPVerifyForm() - return render(request, 'registration/verify_otp.html', {'form': form, 'purpose': 'registration'}) + return render(request, 'registration/verify_otp.html', {'form': form, 'purpose': 'registration', 'otp_method': registration_data.get('otp_method', 'whatsapp')}) def custom_login(request): if request.method == 'POST': - form = AuthenticationForm(request, data=request.POST) + form = CustomLoginForm(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') + otp_method = form.cleaned_data.get('otp_method', 'whatsapp') - # Store user ID in session temporarily + # Store user ID and method in session temporarily request.session['pre_otp_user_id'] = user.id + request.session['pre_otp_method'] = otp_method - # Send OTP - full_phone = profile.full_phone_number - otp = OTPCode.generate_code(full_phone) - msg = _("Your login verification code for MASAR CARGO is: %(code)s") % {"code": 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') + if otp_method == 'email': + if not user.email: + messages.error(request, _("Your account does not have an email address. Please use WhatsApp or contact admin.")) + return redirect('login') + otp = OTPCode.generate_code(email=user.email) + if send_otp_email(user.email, otp.code): + messages.info(request, _("A verification code has been sent to your email.")) + return redirect('verify_otp_login') + else: + messages.error(request, _("Failed to send verification code to your email.")) 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.")) + if not profile.phone_number: + messages.error(request, _("Your account does not have a phone number. Please contact admin.")) + return redirect('login') + + full_phone = profile.full_phone_number + otp = OTPCode.generate_code(phone_number=full_phone) + msg = _("Your login verification code for MASAR CARGO is: %(code)s") % {"code": 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: + messages.error(request, _("Failed to send verification code. Please check your connection.")) else: messages.error(request, _("Invalid username or password.")) else: - form = AuthenticationForm() + form = CustomLoginForm() return render(request, 'registration/login.html', {'form': form}) def verify_otp_login(request): user_id = request.session.get('pre_otp_user_id') + otp_method = request.session.get('pre_otp_method', 'whatsapp') if not user_id: return redirect('login') @@ -185,8 +217,11 @@ def verify_otp_login(request): 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_method == 'email': + otp_record = OTPCode.objects.filter(email=user.email, code=code, is_used=False).last() + else: + 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 @@ -202,7 +237,7 @@ def verify_otp_login(request): else: form = OTPVerifyForm() - return render(request, 'registration/verify_otp.html', {'form': form, 'purpose': 'login'}) + return render(request, 'registration/verify_otp.html', {'form': form, 'purpose': 'login', 'otp_method': otp_method}) @login_required def dashboard(request):