Autosave: 20260520-105030

This commit is contained in:
Flatlogic Bot 2026-05-20 10:50:30 +00:00
parent 2f63d1b7f1
commit f3c8e0ee36
1478 changed files with 290917 additions and 746 deletions

Binary file not shown.

Binary file not shown.

View File

@ -1,20 +1,88 @@
from django.contrib import admin
from django.db.models import Q
from django.utils.html import format_html
from accounts.models import Profile
class GPSAvailabilityFilter(admin.SimpleListFilter):
title = 'GPS availability'
parameter_name = 'gps'
def lookups(self, request, model_admin):
return (
('yes', 'With GPS'),
('no', 'Manual only'),
)
def queryset(self, request, queryset):
if self.value() == 'yes':
return queryset.filter(latitude__isnull=False, longitude__isnull=False)
if self.value() == 'no':
return queryset.filter(Q(latitude__isnull=True) | Q(longitude__isnull=True))
return queryset
@admin.register(Profile)
class ProfileAdmin(admin.ModelAdmin):
list_display = ('user', 'is_seller', 'image_preview')
list_display = (
'user',
'delivery_readiness',
'phone',
'short_location',
'has_precise_location',
'location_updated_at',
'is_seller',
'image_preview',
)
list_select_related = ('user',)
search_fields = ('user__username', 'user__email', 'phone', 'location_label', 'default_address')
list_filter = ('is_seller', GPSAvailabilityFilter, 'location_updated_at')
readonly_fields = ('location_updated_at', 'image_preview', 'maps_link')
autocomplete_fields = ('user',)
fieldsets = (
('Account', {'fields': ('user', 'is_seller')}),
('Delivery defaults', {'fields': ('phone', 'location_label', 'default_address')}),
('GPS location', {'fields': ('latitude', 'longitude', 'location_accuracy_m', 'location_updated_at', 'maps_link')}),
('Profile details', {'fields': ('image', 'image_preview', 'bio')}),
)
@admin.display(description='Delivery readiness')
def delivery_readiness(self, obj):
missing = []
if not obj.phone:
missing.append('phone')
if not obj.formatted_delivery_address:
missing.append('address')
if not obj.has_precise_location:
missing.append('GPS')
if not missing:
return 'Complete'
return 'Missing ' + ', '.join(missing)
@admin.display(description='Location')
def short_location(self, obj):
return obj.short_location or '-'
@admin.display(boolean=True, description='GPS')
def has_precise_location(self, obj):
return obj.has_precise_location
@admin.display(description='Map')
def maps_link(self, obj):
if not obj.has_precise_location:
return '-'
return format_html(
'<a href="https://maps.google.com/?q={},{}" target="_blank" rel="noopener">Open map</a>',
obj.latitude,
obj.longitude,
)
@admin.display(description='Image')
def image_preview(self, obj):
if obj.image:
return format_html(
'<img src="{}" width="40" height="40" '
'style="border-radius: 50%; object-fit: cover;" />',
obj.image.url
'<img src="{}" width="44" height="44" style="border-radius: 50%; object-fit: cover;" />',
obj.image.url,
)
return "No Image"
image_preview.short_description = 'Image'
return 'No image'

View File

@ -1,5 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = 'accounts'
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'accounts'

View File

@ -0,0 +1,128 @@
from decimal import Decimal, InvalidOperation
import re
from django.utils import timezone
def phone_has_valid_digits(phone):
digits = re.sub(r'\D+', '', (phone or '').strip())
return 7 <= len(digits) <= 15
def first_address_line(address):
trimmed = (address or '').strip()
if not trimmed:
return ''
return trimmed.splitlines()[0].strip()
def derive_location_label(location_label='', address=''):
label = (location_label or '').strip()
if label:
return label[:255]
return first_address_line(address)[:255]
def parse_decimal_value(raw_value, *, field_label, minimum=None, maximum=None):
value = (raw_value or '').strip()
if not value:
return None
try:
parsed = Decimal(value)
except (InvalidOperation, TypeError):
raise ValueError(f'Invalid {field_label}.')
if minimum is not None and parsed < minimum:
raise ValueError(f'{field_label.capitalize()} must be at least {minimum}.')
if maximum is not None and parsed > maximum:
raise ValueError(f'{field_label.capitalize()} must be no more than {maximum}.')
return parsed
def stringify_decimal(value):
if value in {None, ''}:
return ''
return str(value)
def build_delivery_payload(
*,
full_name='',
phone='',
address='',
location_label='',
delivery_notes='',
latitude=None,
longitude=None,
location_accuracy_m=None,
save_as_default=True,
**extra,
):
payload = {
'full_name': (full_name or '').strip(),
'phone': (phone or '').strip(),
'address': (address or '').strip(),
'location_label': derive_location_label(location_label, address),
'delivery_notes': (delivery_notes or '').strip(),
'latitude': stringify_decimal(latitude),
'longitude': stringify_decimal(longitude),
'location_accuracy_m': stringify_decimal(location_accuracy_m),
'save_as_default': bool(save_as_default),
}
payload.update(extra)
return payload
def profile_delivery_changes(*, phone, address, location_label='', latitude=None, longitude=None, location_accuracy_m=None):
return {
'phone': (phone or '').strip(),
'default_address': (address or '').strip(),
'location_label': derive_location_label(location_label, address),
'latitude': latitude,
'longitude': longitude,
'location_accuracy_m': location_accuracy_m,
'location_updated_at': timezone.now() if latitude is not None and longitude is not None else None,
}
def apply_profile_delivery_fields(profile, **kwargs):
changes = profile_delivery_changes(**kwargs)
for field, value in changes.items():
setattr(profile, field, value)
return profile
def save_profile_delivery_defaults(profile, **kwargs):
if profile is None:
return []
changes = profile_delivery_changes(**kwargs)
update_fields = []
for field, value in changes.items():
if getattr(profile, field) != value:
setattr(profile, field, value)
update_fields.append(field)
if update_fields:
profile.save(update_fields=update_fields)
return update_fields
def delivery_payload_from_profile(profile, *, full_name='', **extra):
if profile is None:
return build_delivery_payload(full_name=full_name, **extra)
return build_delivery_payload(
full_name=full_name,
phone=profile.phone,
address=profile.formatted_delivery_address,
location_label=profile.short_location,
latitude=profile.latitude,
longitude=profile.longitude,
location_accuracy_m=profile.location_accuracy_m,
save_as_default=True,
**extra,
)

View File

@ -1,27 +1,266 @@
from django import forms
from django.contrib.auth.models import User
from .models import Profile
class ProfileForm(forms.ModelForm):
first_name = forms.CharField(required=False, max_length=30)
last_name = forms.CharField(required=False, max_length=150)
email = forms.EmailField(required=False)
class Meta:
model = Profile
fields = ['image', 'bio']
def save(self, commit=True):
profile = super().save(commit=False)
# update related user fields
user = profile.user
user.first_name = self.cleaned_data.get('first_name', user.first_name)
user.last_name = self.cleaned_data.get('last_name', user.last_name)
email = self.cleaned_data.get('email')
if email:
user.email = email
if commit:
user.save()
profile.save()
return profile
from django import forms
from django.contrib.auth.models import User
from .delivery import apply_profile_delivery_fields, derive_location_label, phone_has_valid_digits
from .models import Profile
class DeliveryLocationValidationMixin:
def clean_phone(self):
phone = (self.cleaned_data.get('phone') or '').strip()
if not phone_has_valid_digits(phone):
raise forms.ValidationError('Please enter a valid phone number.')
return phone
def clean_default_address(self):
address = (self.cleaned_data.get('default_address') or '').strip()
if not address:
raise forms.ValidationError('Please enter your delivery address.')
return address
def clean(self):
cleaned_data = super().clean()
latitude = cleaned_data.get('latitude')
longitude = cleaned_data.get('longitude')
if (latitude is None) != (longitude is None):
raise forms.ValidationError('GPS location is incomplete. Please retry the location button or save without GPS.')
if latitude is not None and not (-90 <= latitude <= 90):
raise forms.ValidationError('Latitude is outside the valid range.')
if longitude is not None and not (-180 <= longitude <= 180):
raise forms.ValidationError('Longitude is outside the valid range.')
location_accuracy_m = cleaned_data.get('location_accuracy_m')
if location_accuracy_m is not None and location_accuracy_m < 0:
raise forms.ValidationError('Location accuracy cannot be negative.')
if cleaned_data.get('default_address') and not cleaned_data.get('location_label'):
cleaned_data['location_label'] = derive_location_label('', cleaned_data['default_address'])
return cleaned_data
def apply_location_to_profile(self, profile):
return apply_profile_delivery_fields(
profile,
phone=self.cleaned_data.get('phone', ''),
location_label=self.cleaned_data.get('location_label', ''),
address=self.cleaned_data.get('default_address', ''),
latitude=self.cleaned_data.get('latitude'),
longitude=self.cleaned_data.get('longitude'),
location_accuracy_m=self.cleaned_data.get('location_accuracy_m'),
)
class RegistrationForm(DeliveryLocationValidationMixin, forms.Form):
username = forms.CharField(
min_length=3,
max_length=150,
widget=forms.TextInput(
attrs={
'placeholder': 'Choose a username',
'autocomplete': 'username',
}
),
)
first_name = forms.CharField(
required=False,
max_length=30,
widget=forms.TextInput(attrs={'placeholder': 'First name', 'autocomplete': 'given-name'}),
)
last_name = forms.CharField(
required=False,
max_length=150,
widget=forms.TextInput(attrs={'placeholder': 'Last name', 'autocomplete': 'family-name'}),
)
email = forms.EmailField(
required=False,
widget=forms.EmailInput(attrs={'placeholder': 'your@email.com', 'autocomplete': 'email'}),
)
password = forms.CharField(
min_length=6,
widget=forms.PasswordInput(attrs={'placeholder': 'At least 6 characters', 'autocomplete': 'new-password'}),
)
confirm_password = forms.CharField(
min_length=6,
widget=forms.PasswordInput(attrs={'placeholder': 'Confirm your password', 'autocomplete': 'new-password'}),
)
phone = forms.CharField(
max_length=30,
required=True,
widget=forms.TextInput(attrs={'placeholder': 'Phone number', 'autocomplete': 'tel'}),
)
location_label = forms.CharField(
max_length=255,
required=False,
widget=forms.TextInput(
attrs={
'placeholder': 'Area / city / delivery label',
'autocomplete': 'address-level2',
}
),
)
default_address = forms.CharField(
required=True,
widget=forms.Textarea(
attrs={
'rows': 4,
'placeholder': 'Street, city, area, landmark',
'autocomplete': 'street-address',
}
),
)
latitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput())
longitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput())
location_accuracy_m = forms.DecimalField(required=False, max_digits=8, decimal_places=2, widget=forms.HiddenInput())
register_as_seller = forms.BooleanField(required=False)
def clean_username(self):
username = (self.cleaned_data.get('username') or '').strip()
if User.objects.filter(username__iexact=username).exists():
raise forms.ValidationError('Username already exists.')
return username
def clean_email(self):
email = (self.cleaned_data.get('email') or '').strip()
if email and User.objects.filter(email__iexact=email).exists():
raise forms.ValidationError('Email already registered.')
return email
def clean(self):
cleaned_data = super().clean()
password = cleaned_data.get('password')
confirm_password = cleaned_data.get('confirm_password')
if password and confirm_password and password != confirm_password:
self.add_error('confirm_password', 'Passwords do not match.')
return cleaned_data
def save(self):
user = User.objects.create_user(
username=self.cleaned_data['username'],
password=self.cleaned_data['password'],
email=self.cleaned_data.get('email', ''),
)
user.first_name = self.cleaned_data.get('first_name', '').strip()
user.last_name = self.cleaned_data.get('last_name', '').strip()
user.save(update_fields=['first_name', 'last_name', 'email'])
profile = user.profile
self.apply_location_to_profile(profile)
profile.is_seller = self.cleaned_data.get('register_as_seller', False)
profile.save()
return user
class ProfileForm(DeliveryLocationValidationMixin, forms.ModelForm):
first_name = forms.CharField(required=False, max_length=30)
last_name = forms.CharField(required=False, max_length=150)
email = forms.EmailField(required=False)
phone = forms.CharField(
max_length=30,
required=True,
widget=forms.TextInput(attrs={'placeholder': 'Phone number', 'autocomplete': 'tel'}),
)
location_label = forms.CharField(
max_length=255,
required=False,
widget=forms.TextInput(
attrs={
'placeholder': 'Area / city / delivery label',
'autocomplete': 'address-level2',
}
),
)
default_address = forms.CharField(
required=True,
widget=forms.Textarea(
attrs={
'rows': 4,
'placeholder': 'Street, city, area, landmark',
'autocomplete': 'street-address',
}
),
)
latitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput())
longitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput())
location_accuracy_m = forms.DecimalField(required=False, max_digits=8, decimal_places=2, widget=forms.HiddenInput())
class Meta:
model = Profile
fields = ['image', 'bio', 'phone', 'location_label', 'default_address', 'latitude', 'longitude', 'location_accuracy_m']
widgets = {
'bio': forms.Textarea(attrs={'rows': 5, 'placeholder': 'Tell shoppers something about yourself'}),
}
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
self.user = user or getattr(self.instance, 'user', None)
if self.user:
self.fields['first_name'].initial = self.user.first_name
self.fields['last_name'].initial = self.user.last_name
self.fields['email'].initial = self.user.email
def clean_email(self):
email = (self.cleaned_data.get('email') or '').strip()
if email and User.objects.exclude(pk=getattr(self.user, 'pk', None)).filter(email__iexact=email).exists():
raise forms.ValidationError('That email is already being used by another account.')
return email
def save(self, commit=True):
profile = super().save(commit=False)
self.apply_location_to_profile(profile)
user = self.user or profile.user
user.first_name = self.cleaned_data.get('first_name', '').strip()
user.last_name = self.cleaned_data.get('last_name', '').strip()
user.email = self.cleaned_data.get('email', '').strip()
if commit:
user.save()
profile.save()
return profile
class DeliveryPreferencesForm(DeliveryLocationValidationMixin, forms.ModelForm):
phone = forms.CharField(
max_length=30,
required=True,
widget=forms.TextInput(attrs={'placeholder': 'Phone number', 'autocomplete': 'tel'}),
)
location_label = forms.CharField(
max_length=255,
required=False,
widget=forms.TextInput(
attrs={
'placeholder': 'Area / city / delivery label',
'autocomplete': 'address-level2',
}
),
)
default_address = forms.CharField(
required=True,
widget=forms.Textarea(
attrs={
'rows': 4,
'placeholder': 'Street, city, area, landmark',
'autocomplete': 'street-address',
}
),
)
latitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput())
longitude = forms.DecimalField(required=False, max_digits=9, decimal_places=6, widget=forms.HiddenInput())
location_accuracy_m = forms.DecimalField(required=False, max_digits=8, decimal_places=2, widget=forms.HiddenInput())
class Meta:
model = Profile
fields = ['phone', 'location_label', 'default_address', 'latitude', 'longitude', 'location_accuracy_m']
def save(self, commit=True):
profile = super().save(commit=False)
self.apply_location_to_profile(profile)
if commit:
profile.save()
return profile

View File

@ -0,0 +1,48 @@
# Generated by Django 5.2.14 on 2026-05-20 09:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_profile_is_seller'),
]
operations = [
migrations.AddField(
model_name='profile',
name='default_address',
field=models.TextField(blank=True, default=''),
),
migrations.AddField(
model_name='profile',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
migrations.AddField(
model_name='profile',
name='location_accuracy_m',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
),
migrations.AddField(
model_name='profile',
name='location_label',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AddField(
model_name='profile',
name='location_updated_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='profile',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
migrations.AddField(
model_name='profile',
name='phone',
field=models.CharField(blank=True, default='', max_length=30),
),
]

View File

@ -1,31 +1,54 @@
from django.db import models
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver
def user_profile_upload_path(instance, filename):
# Files will be uploaded to MEDIA_ROOT/profile_pics/user_<id>/<filename>
return f'profile_pics/user_{instance.user.id}/{filename}'
from django.contrib.auth.models import User
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
def user_profile_upload_path(instance, filename):
# Files will be uploaded to MEDIA_ROOT/profile_pics/user_<id>/<filename>
return f'profile_pics/user_{instance.user.id}/{filename}'
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
bio = models.TextField(blank=True, null=True)
image = models.ImageField(upload_to=user_profile_upload_path, blank=True, null=True)
is_seller = models.BooleanField(default=False)
def __str__(self):
return f'Profile for {self.user.username}'
@receiver(post_save, sender=User)
def ensure_profile_exists(sender, instance, created, **kwargs):
if created:
Profile.objects.create(user=instance)
else:
# save existing profile to ensure any related signals run
try:
instance.profile.save()
except Exception:
pass
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
bio = models.TextField(blank=True, null=True)
image = models.ImageField(upload_to=user_profile_upload_path, blank=True, null=True)
is_seller = models.BooleanField(default=False)
phone = models.CharField(max_length=30, blank=True, default='')
location_label = models.CharField(max_length=255, blank=True, default='')
default_address = models.TextField(blank=True, default='')
latitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True)
longitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True)
location_accuracy_m = models.DecimalField(max_digits=8, decimal_places=2, blank=True, null=True)
location_updated_at = models.DateTimeField(blank=True, null=True)
def __str__(self):
return f'Profile for {self.user.username}'
@property
def short_location(self):
if self.location_label and self.location_label.strip():
return self.location_label.strip()
if self.default_address and self.default_address.strip():
return self.default_address.splitlines()[0].strip()
return ''
@property
def formatted_delivery_address(self):
return (self.default_address or '').strip()
@property
def has_precise_location(self):
return self.latitude is not None and self.longitude is not None
@receiver(post_save, sender=User)
def ensure_profile_exists(sender, instance, created, **kwargs):
if created:
Profile.objects.create(user=instance)
else:
# save existing profile to ensure any related signals run
try:
instance.profile.save()
except Exception:
pass

View File

@ -1,3 +1,109 @@
from django.test import TestCase
# Create your tests here.
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from accounts.forms import DeliveryPreferencesForm
User = get_user_model()
class AccountRegistrationTests(TestCase):
def test_registration_creates_profile_with_delivery_location(self):
response = self.client.post(
reverse('register'),
{
'username': 'newbuyer',
'first_name': 'New',
'last_name': 'Buyer',
'email': 'newbuyer@example.com',
'password': 'strongpass123',
'confirm_password': 'strongpass123',
'phone': '+9779800000001',
'location_label': 'Boudha, Kathmandu',
'default_address': 'Boudha Stupa Gate\nKathmandu',
'latitude': '27.721900',
'longitude': '85.361100',
'location_accuracy_m': '14.50',
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], reverse('login'))
user = User.objects.get(username='newbuyer')
self.assertEqual(user.first_name, 'New')
self.assertEqual(user.last_name, 'Buyer')
self.assertEqual(user.profile.phone, '+9779800000001')
self.assertEqual(user.profile.location_label, 'Boudha, Kathmandu')
self.assertEqual(user.profile.default_address, 'Boudha Stupa Gate\nKathmandu')
self.assertIsNotNone(user.profile.latitude)
self.assertIsNotNone(user.profile.longitude)
self.assertIsNotNone(user.profile.location_updated_at)
def test_registration_defaults_location_label_from_address_when_blank(self):
self.client.post(
reverse('register'),
{
'username': 'autolabel',
'first_name': 'Auto',
'last_name': 'Label',
'email': 'autolabel@example.com',
'password': 'strongpass123',
'confirm_password': 'strongpass123',
'phone': '+9779800000002',
'location_label': '',
'default_address': 'Imadol Height\nLalitpur',
},
)
user = User.objects.get(username='autolabel')
self.assertEqual(user.profile.location_label, 'Imadol Height')
def test_registration_requires_phone_and_address(self):
response = self.client.post(
reverse('register'),
{
'username': 'missinglocation',
'password': 'strongpass123',
'confirm_password': 'strongpass123',
'phone': '',
'default_address': '',
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Please correct the highlighted fields and try again.')
self.assertFalse(User.objects.filter(username='missinglocation').exists())
class DeliveryPreferencesFormTests(TestCase):
def test_saving_manual_address_clears_stale_gps_from_profile(self):
user = User.objects.create_user(username='gpsuser', password='password123')
profile = user.profile
profile.phone = '+9779811111111'
profile.location_label = 'Old Baneshwor'
profile.default_address = 'Old Address\nKathmandu'
profile.latitude = '27.700000'
profile.longitude = '85.300000'
profile.location_accuracy_m = '9.50'
profile.save()
form = DeliveryPreferencesForm(
data={
'phone': '+9779801234567',
'location_label': 'Manual only',
'default_address': 'New Road\nBhaktapur',
},
instance=profile,
)
self.assertTrue(form.is_valid(), form.errors)
form.save()
profile.refresh_from_db()
self.assertEqual(profile.phone, '+9779801234567')
self.assertEqual(profile.default_address, 'New Road\nBhaktapur')
self.assertIsNone(profile.latitude)
self.assertIsNone(profile.longitude)
self.assertIsNone(profile.location_accuracy_m)
self.assertIsNone(profile.location_updated_at)

View File

@ -1,13 +1,39 @@
from django.contrib import messages
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.db.models import Sum
from django.shortcuts import redirect, render
from orders.models import Order
from products.models import WishlistItem
from .forms import ProfileForm, RegistrationForm
from .models import Profile
def _sync_delivery_location_session(request, user):
profile = getattr(user, 'profile', None)
if profile and profile.short_location:
request.session['delivery_location'] = profile.short_location
else:
request.session.pop('delivery_location', None)
def _recent_delivery_points(order_queryset, *, limit=3):
points = []
seen = set()
for order in order_queryset.exclude(address='').order_by('-created_at'):
key = (order.phone.strip(), order.address.strip(), order.location_label.strip())
if key in seen:
continue
seen.add(key)
points.append(order)
if len(points) >= limit:
break
return points
def login_view(request):
if request.user.is_authenticated:
@ -24,6 +50,7 @@ def login_view(request):
if user:
login(request, user)
_sync_delivery_location_session(request, user)
messages.success(request, f'Welcome back, {username}!')
return redirect('profile')
@ -37,41 +64,28 @@ def register_view(request):
return redirect('profile')
if request.method == 'POST':
username = request.POST.get('username', '').strip()
password = request.POST.get('password', '').strip()
confirm_password = request.POST.get('confirm_password', '').strip()
email = request.POST.get('email', '').strip()
register_as_seller = request.POST.get('register_as_seller') == 'on'
if not username or not password or not confirm_password:
return render(request, 'accounts/register.html', {'error': 'All fields are required', 'username': username, 'email': email, 'register_as_seller': register_as_seller})
if len(username) < 3:
return render(request, 'accounts/register.html', {'error': 'Username must be at least 3 characters long', 'username': username, 'email': email, 'register_as_seller': register_as_seller})
if len(password) < 6:
return render(request, 'accounts/register.html', {'error': 'Password must be at least 6 characters long', 'username': username, 'email': email, 'register_as_seller': register_as_seller})
if password != confirm_password:
return render(request, 'accounts/register.html', {'error': 'Passwords do not match', 'username': username, 'email': email, 'register_as_seller': register_as_seller})
if User.objects.filter(username=username).exists():
return render(request, 'accounts/register.html', {'error': 'Username already exists', 'email': email, 'register_as_seller': register_as_seller})
if email and User.objects.filter(email=email).exists():
return render(request, 'accounts/register.html', {'error': 'Email already registered', 'username': username, 'register_as_seller': register_as_seller})
user = User.objects.create_user(username=username, password=password, email=email)
if register_as_seller:
user.profile.is_seller = True
user.profile.save(update_fields=['is_seller'])
messages.success(request, 'Account created successfully! Please log in.')
return redirect('login')
form = RegistrationForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, 'Account created successfully! Please log in.')
return redirect('login')
messages.error(request, 'Please correct the highlighted fields and try again.')
else:
form = RegistrationForm(initial={'register_as_seller': request.GET.get('seller') == '1'})
return render(
request,
'accounts/register.html',
{'register_as_seller': request.GET.get('seller') == '1'},
{
'form': form,
'register_as_seller': request.GET.get('seller') == '1',
},
)
def logout_view(request):
logout(request)
request.session.pop('delivery_location', None)
return redirect('/')
@ -80,53 +94,53 @@ def profile_view(request):
user_orders = Order.objects.filter(user=request.user)
delivered_orders = user_orders.filter(status='Delivered')
recent_orders = user_orders.order_by('-created_at')[:5]
recent_delivery_points = _recent_delivery_points(user_orders, limit=3)
profile = getattr(request.user, 'profile', None)
total_spent = delivered_orders.aggregate(total=Sum('total_price')).get('total') or 0
wishlist_count = WishlistItem.objects.filter(user=request.user).count()
profile_checks = [
bool(request.user.email),
bool(profile and profile.phone),
bool(profile and profile.formatted_delivery_address),
bool(profile and profile.has_precise_location),
]
profile_completion = int(sum(profile_checks) / len(profile_checks) * 100) if profile_checks else 0
return render(
request,
'accounts/profile.html',
{
'user': request.user,
'profile': profile,
'orders_count': user_orders.count(),
'delivered_count': delivered_orders.count(),
'pending_count': user_orders.exclude(status='Delivered').count(),
'wishlist_count': wishlist_count,
'total_spent': total_spent,
'recent_orders': recent_orders,
'recent_delivery_points': recent_delivery_points,
'profile_completion': profile_completion,
},
)
@login_required
def edit_profile(request):
from .forms import ProfileForm
profile = getattr(request.user, 'profile', None)
if profile is None:
# ensure profile exists
from .models import Profile
profile = Profile.objects.create(user=request.user)
if request.method == 'POST':
form = ProfileForm(request.POST, request.FILES, instance=profile)
# populate user fields into form for display/save
form.fields['first_name'].initial = request.user.first_name
form.fields['last_name'].initial = request.user.last_name
form.fields['email'].initial = request.user.email
form = ProfileForm(request.POST, request.FILES, instance=profile, user=request.user)
if form.is_valid():
form.save()
profile = form.save()
_sync_delivery_location_session(request, request.user)
messages.success(request, 'Profile updated successfully.')
return redirect('profile')
else:
messages.error(request, 'Please correct the errors below.')
messages.error(request, 'Please correct the errors below.')
else:
form = ProfileForm(instance=profile)
form.fields['first_name'].initial = request.user.first_name
form.fields['last_name'].initial = request.user.last_name
form.fields['email'].initial = request.user.email
form = ProfileForm(instance=profile, user=request.user)
return render(request, 'accounts/edit_profile.html', {'form': form, 'profile': profile})

Binary file not shown.

View File

@ -1,5 +1,6 @@
from django.apps import AppConfig
class CartConfig(AppConfig):
name = 'cart'
from django.apps import AppConfig
class CartConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'cart'

View File

@ -120,12 +120,16 @@ def cart_view(request):
cart = get_cart(request)
products = []
subtotal = Decimal('0')
item_count = 0
saved_amount = Decimal('0')
for id, qty in cart.items():
product = get_object_or_404(Product, id=int(id))
product.qty = qty
product.subtotal = product.display_price * qty
subtotal += product.subtotal
item_count += qty
saved_amount += Decimal(str(product.savings or 0)) * qty
products.append(product)
shipping = Decimal('60') if products else Decimal('0')
@ -155,5 +159,7 @@ def cart_view(request):
'grand_total': grand_total,
'coupon_code': coupon_code,
'coupon': coupon,
'item_count': item_count,
'saved_amount': saved_amount,
},
)
)

Binary file not shown.

View File

@ -142,10 +142,14 @@ def language_context(request):
seen.add(default_category['value'].lower())
delivery_location = request.session.get('delivery_location', '').strip()
if not delivery_location and request.user.is_authenticated:
last_address = Order.objects.filter(user=request.user).exclude(address='').order_by('-created_at').values_list('address', flat=True).first()
if last_address:
delivery_location = last_address.split('\n', 1)[0].strip()
if request.user.is_authenticated:
profile = getattr(request.user, 'profile', None)
if profile and profile.short_location:
delivery_location = profile.short_location
elif not delivery_location:
last_address = Order.objects.filter(user=request.user).exclude(address='').order_by('-created_at').values_list('address', flat=True).first()
if last_address:
delivery_location = last_address.split('\n', 1)[0].strip()
return {
'site_language': site_language,

View File

@ -1,8 +1,11 @@
from decimal import Decimal
from django.db.models import Avg
from django.contrib import messages
from django.db.models import Avg, Count, Q
from django.shortcuts import redirect, render
from accounts.forms import DeliveryPreferencesForm
from accounts.models import Profile
from products.models import Product
@ -18,6 +21,26 @@ def _format_discount_offer(product):
}
def _build_category_spotlights():
top_categories = list(
Product.objects.exclude(category='')
.values('category')
.annotate(
total=Count('id'),
featured_total=Count('id', filter=Q(featured=True)),
avg_rating=Avg('rating'),
)
.order_by('-total', 'category')[:4]
)
for item in top_categories:
category_products = Product.objects.filter(category=item['category'])
item['avg_rating'] = item.get('avg_rating') or 0
item['best_price'] = min((product.display_price for product in category_products), default=Decimal('0'))
return top_categories
def home(request):
trending_products = Product.objects.filter(featured=True).order_by('-created_at')[:4]
if not trending_products.exists():
@ -40,24 +63,30 @@ def home(request):
if offer_deal:
special_deals.append(offer_deal)
special_deals.append({
'title': f'{len(discounted_products)} offers live',
'description': 'Discounts available across top Nepali categories.',
'note': 'Browse products with extra savings today.',
})
special_deals.append(
{
'title': f'{len(discounted_products)} offers live',
'description': 'Discounts available across top Nepali categories.',
'note': 'Browse products with extra savings today.',
}
)
if featured_count:
special_deals.append({
'title': 'Featured Seller Picks',
'description': f'{featured_count} curated products from trusted sellers.',
'note': 'Popular with Nepali shoppers.',
})
special_deals.append(
{
'title': 'Featured Seller Picks',
'description': f'{featured_count} curated products from trusted sellers.',
'note': 'Popular with Nepali shoppers.',
}
)
else:
special_deals.append({
'title': 'Daily Deals',
'description': 'Fresh discount offers updated every day.',
'note': 'Check back for more savings.',
})
special_deals.append(
{
'title': 'Daily Deals',
'description': 'Fresh discount offers updated every day.',
'note': 'Check back for more savings.',
}
)
advertised_products = sorted(
discounted_products,
@ -68,7 +97,7 @@ def home(request):
if not advertised_products:
advertised_products = list(trending_products[:4])
for index, product in enumerate(advertised_products):
for product in advertised_products:
savings = (product.price - product.discount_price) if product.discount_price else Decimal('0')
percent = int((savings / product.price * Decimal('100')).quantize(Decimal('1'))) if product.price else 0
if percent >= 50:
@ -83,6 +112,29 @@ def home(request):
label = 'Featured Deal'
product.deal_label = label
experience_highlights = [
{
'title': 'Verified payments',
'description': 'Orders are only marked paid after wallet or card verification is completed on the server.',
'badge': 'Trusted checkout',
},
{
'title': 'Flexible payment flow',
'description': 'Customers can start with cash on delivery and later switch to eSewa or Khalti before shipment.',
'badge': 'Wallet ready',
},
{
'title': 'Smart product discovery',
'description': 'Search, categories, sorting, wishlist, and featured filters make it easier to find the right item fast.',
'badge': 'Fast browsing',
},
{
'title': 'Trackable orders',
'description': 'Order status, payment status, and next actions are visible from one clean dashboard-style experience.',
'badge': 'Clear visibility',
},
]
return render(
request,
'core/home.html',
@ -94,6 +146,8 @@ def home(request):
'avg_rating': aggregates.get('avg_rating') or 0,
'special_deals': special_deals,
'advertised_products': advertised_products,
'category_spotlights': _build_category_spotlights(),
'experience_highlights': experience_highlights,
},
)
@ -128,12 +182,44 @@ def landing(request):
def settings_view(request):
if request.method == 'POST':
delivery_location = request.POST.get('delivery_location', '').strip()
if delivery_location:
request.session['delivery_location'] = delivery_location
location_form = None
guest_delivery_location = request.session.get('delivery_location', '').strip()
profile = None
if request.user.is_authenticated:
profile = getattr(request.user, 'profile', None)
if profile is None:
profile = Profile.objects.create(user=request.user)
if request.method == 'POST':
location_form = DeliveryPreferencesForm(request.POST, instance=profile)
if location_form.is_valid():
profile = location_form.save()
if profile.short_location:
request.session['delivery_location'] = profile.short_location
else:
request.session.pop('delivery_location', None)
messages.success(request, 'Delivery preferences updated successfully.')
return redirect('settings')
messages.error(request, 'Please correct the location details below.')
else:
location_form = DeliveryPreferencesForm(instance=profile)
elif request.method == 'POST':
guest_delivery_location = request.POST.get('delivery_location', '').strip()
if guest_delivery_location:
request.session['delivery_location'] = guest_delivery_location
messages.success(request, 'Temporary delivery location saved for this browser.')
else:
request.session.pop('delivery_location', None)
messages.info(request, 'Temporary delivery location cleared.')
return redirect('settings')
return render(request, 'core/settings.html')
return render(
request,
'core/settings.html',
{
'location_form': location_form,
'guest_delivery_location': guest_delivery_location,
'location_saved_at': getattr(profile, 'location_updated_at', None),
},
)

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@ -10,6 +10,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/
"""
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
@ -27,6 +28,8 @@ DEBUG = True
ALLOWED_HOSTS = ['localhost', '127.0.0.1', '[::1]', 'testserver', '.flatlogic.app']
CSRF_TRUSTED_ORIGINS = ['https://*.flatlogic.app', 'http://*.flatlogic.app']
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
USE_X_FORWARDED_HOST = True
# Application definition
@ -43,7 +46,6 @@ INSTALLED_APPS = [
'cart', # cart app for shopping cart functionality
'orders', # orders app for order management
'products', # products app for product management
]
MIDDLEWARE = [
@ -61,7 +63,7 @@ ROOT_URLCONF = 'myproject.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'tempelates'], # ✅ FIX HERE
'DIRS': [BASE_DIR / 'tempelates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@ -128,10 +130,46 @@ USE_TZ = True
STATIC_URL = '/static/'
STATICFILES_DIRS = [
BASE_DIR / "static",
BASE_DIR / 'static',
]
# Authentication Settings
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'profile'
LOGOUT_REDIRECT_URL = 'home'
# Payment configuration
PUBLIC_APP_URL = os.getenv('PUBLIC_APP_URL', '').strip().rstrip('/')
PAYMENT_CURRENCY = (os.getenv('PAYMENT_CURRENCY', 'NPR').strip() or 'NPR').upper()
# Stripe fallback for card payments
STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY', '').strip()
STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET', '').strip()
STRIPE_CURRENCY = (os.getenv('STRIPE_CURRENCY', PAYMENT_CURRENCY.lower()).strip() or PAYMENT_CURRENCY.lower()).lower()
# eSewa ePay (defaults to official UAT credentials in this dev environment)
ESEWA_SANDBOX = (os.getenv('ESEWA_SANDBOX', '1').strip() or '1').lower() not in {'0', 'false', 'no'}
ESEWA_PRODUCT_CODE = os.getenv('ESEWA_PRODUCT_CODE', 'EPAYTEST' if ESEWA_SANDBOX else '').strip()
ESEWA_SECRET_KEY = os.getenv('ESEWA_SECRET_KEY', '8gBm/:&EnhH.1/q' if ESEWA_SANDBOX else '').strip()
ESEWA_FORM_URL = os.getenv(
'ESEWA_FORM_URL',
'https://rc-epay.esewa.com.np/api/epay/main/v2/form' if ESEWA_SANDBOX else 'https://epay.esewa.com.np/api/epay/main/v2/form',
).strip()
ESEWA_STATUS_URL = os.getenv(
'ESEWA_STATUS_URL',
'https://rc.esewa.com.np/api/epay/transaction/status/' if ESEWA_SANDBOX else 'https://epay.esewa.com.np/api/epay/transaction/status/',
).strip()
# Khalti KPG-2
KHALTI_SANDBOX = (os.getenv('KHALTI_SANDBOX', '1').strip() or '1').lower() not in {'0', 'false', 'no'}
KHALTI_SECRET_KEY = os.getenv('KHALTI_SECRET_KEY', '').strip()
KHALTI_INITIATE_URL = os.getenv(
'KHALTI_INITIATE_URL',
'https://dev.khalti.com/api/v2/epayment/initiate/' if KHALTI_SANDBOX else 'https://khalti.com/api/v2/epayment/initiate/',
).strip()
KHALTI_LOOKUP_URL = os.getenv(
'KHALTI_LOOKUP_URL',
'https://dev.khalti.com/api/v2/epayment/lookup/' if KHALTI_SANDBOX else 'https://khalti.com/api/v2/epayment/lookup/',
).strip()
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'

Binary file not shown.

Binary file not shown.

View File

@ -1,8 +1,30 @@
from django.contrib import admin
from django.db.models import Q
from django.template.defaultfilters import linebreaksbr
from django.utils import timezone
from django.utils.html import format_html, format_html_join
from .models import Order, OrderItem
class GPSAvailabilityFilter(admin.SimpleListFilter):
title = 'GPS availability'
parameter_name = 'gps'
def lookups(self, request, model_admin):
return (
('yes', 'With GPS'),
('no', 'Manual only'),
)
def queryset(self, request, queryset):
if self.value() == 'yes':
return queryset.filter(latitude__isnull=False, longitude__isnull=False)
if self.value() == 'no':
return queryset.filter(Q(latitude__isnull=True) | Q(longitude__isnull=True))
return queryset
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0
@ -10,10 +32,129 @@ class OrderItemInline(admin.TabularInline):
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ('id', 'user', 'status', 'payment_method', 'total_price', 'created_at')
list_filter = ('status', 'payment_method')
search_fields = ('user__username', 'id')
list_display = (
'id',
'user',
'status',
'payment_status',
'payment_method',
'payment_provider_label',
'short_location',
'has_precise_location',
'total_price',
'created_at',
'paid_at',
)
list_select_related = ('user',)
list_filter = ('status', 'payment_status', 'payment_provider', 'payment_method', GPSAvailabilityFilter)
search_fields = (
'user__username',
'user__email',
'id',
'payment_reference',
'payment_session_id',
'full_name',
'phone',
'address',
'location_label',
'delivery_notes',
)
readonly_fields = ('created_at', 'payment_reference', 'payment_session_id', 'paid_at', 'delivery_snapshot', 'maps_link')
date_hierarchy = 'created_at'
autocomplete_fields = ('user',)
actions = ('mark_payment_pending', 'mark_payment_paid', 'mark_status_shipped', 'mark_status_delivered')
fieldsets = (
('Order', {'fields': ('user', 'status', 'created_at', 'total_price')}),
(
'Payment',
{
'fields': (
'payment_status',
'payment_method',
'payment_provider',
'payment_currency',
'payment_reference',
'payment_session_id',
'paid_at',
)
},
),
('Delivery snapshot', {'fields': ('full_name', 'phone', 'location_label', 'address', 'delivery_notes', 'delivery_snapshot')}),
('GPS', {'fields': ('latitude', 'longitude', 'location_accuracy_m', 'maps_link')}),
)
inlines = [OrderItemInline]
@admin.display(description='Location')
def short_location(self, obj):
return obj.location_label or (obj.address.splitlines()[0].strip() if obj.address else '-')
admin.site.register(OrderItem)
@admin.display(boolean=True, description='GPS')
def has_precise_location(self, obj):
return obj.has_precise_location
@admin.display(description='Payment provider')
def payment_provider_label(self, obj):
return obj.payment_provider_label
@admin.display(description='Delivery summary')
def delivery_snapshot(self, obj):
lines = []
if obj.full_name:
lines.append(format_html('<strong>{}</strong>', obj.full_name))
if obj.phone:
lines.append(obj.phone)
if obj.location_label:
lines.append(obj.location_label)
if obj.address:
lines.append(linebreaksbr(obj.address))
if obj.delivery_notes:
lines.append(format_html('<em>Notes:</em> {}', obj.delivery_notes))
if not lines:
return '-'
return format_html_join(format_html('<br>'), '{}', ((line,) for line in lines))
@admin.display(description='Map')
def maps_link(self, obj):
if not obj.has_precise_location:
return '-'
return format_html(
'<a href="https://maps.google.com/?q={},{}" target="_blank" rel="noopener">Open map</a>',
obj.latitude,
obj.longitude,
)
@admin.action(description='Mark selected orders as payment pending')
def mark_payment_pending(self, request, queryset):
updated = queryset.exclude(payment_status='Pending').update(payment_status='Pending')
self.message_user(request, f'{updated} order(s) marked as payment pending.')
@admin.action(description='Mark selected orders as paid')
def mark_payment_paid(self, request, queryset):
now = timezone.now()
updated = queryset.exclude(payment_status='Paid').update(payment_status='Paid', paid_at=now)
queryset.filter(status='Pending').update(status='Paid')
self.message_user(request, f'{updated} order(s) marked as paid.')
@admin.action(description='Mark selected orders as shipped')
def mark_status_shipped(self, request, queryset):
updated = queryset.exclude(status__in=['Shipped', 'Delivered']).update(status='Shipped')
self.message_user(request, f'{updated} order(s) marked as shipped.')
@admin.action(description='Mark selected orders as delivered')
def mark_status_delivered(self, request, queryset):
updated = queryset.exclude(status='Delivered').update(status='Delivered')
cod_collected = queryset.filter(payment_method='Cash on Delivery').exclude(payment_status='Paid').update(
payment_status='Paid',
paid_at=timezone.now(),
)
message = f'{updated} order(s) marked as delivered.'
if cod_collected:
message += f' {cod_collected} COD payment(s) were also marked paid.'
self.message_user(request, message)
@admin.register(OrderItem)
class OrderItemAdmin(admin.ModelAdmin):
list_display = ('order', 'product', 'quantity', 'price')
search_fields = ('order__id', 'product__name')
autocomplete_fields = ('order', 'product')

View File

@ -0,0 +1,58 @@
# Generated by Django 5.2.14 on 2026-05-20 02:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0004_order_delivery_fields'),
]
operations = [
migrations.AddField(
model_name='order',
name='paid_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='order',
name='payment_currency',
field=models.CharField(blank=True, default='NPR', max_length=10),
),
migrations.AddField(
model_name='order',
name='payment_provider',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.AddField(
model_name='order',
name='payment_reference',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AddField(
model_name='order',
name='payment_session_id',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AddField(
model_name='order',
name='payment_status',
field=models.CharField(choices=[('Unpaid', 'Unpaid'), ('Pending', 'Pending'), ('Paid', 'Paid'), ('Failed', 'Failed')], default='Unpaid', max_length=20),
),
migrations.AlterField(
model_name='order',
name='id',
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='order',
name='payment_method',
field=models.CharField(default='Pending selection', max_length=100),
),
migrations.AlterField(
model_name='orderitem',
name='id',
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

@ -0,0 +1,38 @@
# Generated by Django 5.2.14 on 2026-05-20 09:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0005_order_paid_at_order_payment_currency_and_more'),
]
operations = [
migrations.AddField(
model_name='order',
name='delivery_notes',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AddField(
model_name='order',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
migrations.AddField(
model_name='order',
name='location_accuracy_m',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
),
migrations.AddField(
model_name='order',
name='location_label',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AddField(
model_name='order',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
]

View File

@ -1,5 +1,5 @@
from django.db import models
from django.contrib.auth.models import User
from django.db import models
from products.models import Product
@ -11,15 +11,39 @@ class Order(models.Model):
('Shipped', 'Shipped'),
('Delivered', 'Delivered'),
)
PAYMENT_STATUS_CHOICES = (
('Unpaid', 'Unpaid'),
('Pending', 'Pending'),
('Paid', 'Paid'),
('Failed', 'Failed'),
)
PAYMENT_PROVIDER_LABELS = {
'esewa': 'eSewa',
'khalti': 'Khalti',
'fonepay': 'Fonepay',
'stripe': 'Stripe',
'offline': 'Offline',
}
user = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
total_price = models.DecimalField(max_digits=10, decimal_places=2)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='Pending')
payment_method = models.CharField(max_length=100, default='COD')
payment_status = models.CharField(max_length=20, choices=PAYMENT_STATUS_CHOICES, default='Unpaid')
payment_method = models.CharField(max_length=100, default='Pending selection')
payment_provider = models.CharField(max_length=50, blank=True, default='')
payment_reference = models.CharField(max_length=255, blank=True, default='')
payment_session_id = models.CharField(max_length=255, blank=True, default='')
payment_currency = models.CharField(max_length=10, blank=True, default='NPR')
paid_at = models.DateTimeField(null=True, blank=True)
full_name = models.CharField(max_length=150, blank=True, default='')
phone = models.CharField(max_length=30, blank=True, default='')
address = models.TextField(blank=True, default='')
location_label = models.CharField(max_length=255, blank=True, default='')
latitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True)
longitude = models.DecimalField(max_digits=9, decimal_places=6, blank=True, null=True)
location_accuracy_m = models.DecimalField(max_digits=8, decimal_places=2, blank=True, null=True)
delivery_notes = models.CharField(max_length=255, blank=True, default='')
class Meta:
ordering = ['-created_at']
@ -27,6 +51,16 @@ class Order(models.Model):
def __str__(self):
return f"Order #{self.id} - {self.user.username}"
@property
def payment_provider_label(self):
if not self.payment_provider:
return '-'
return self.PAYMENT_PROVIDER_LABELS.get(self.payment_provider, self.payment_provider)
@property
def has_precise_location(self):
return self.latitude is not None and self.longitude is not None
class OrderItem(models.Model):
order = models.ForeignKey(Order, related_name='items', on_delete=models.CASCADE)
@ -35,4 +69,4 @@ class OrderItem(models.Model):
price = models.DecimalField(max_digits=10, decimal_places=2)
def __str__(self):
return f"{self.quantity} x {self.product.name} @ {self.price}"
return f"{self.quantity} x {self.product.name} @ {self.price}"

View File

@ -0,0 +1,497 @@
import base64
import hashlib
import hmac
import json
import uuid
from decimal import Decimal, ROUND_HALF_UP
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, urljoin
from urllib.request import Request, urlopen
from django.conf import settings
from django.urls import reverse
import stripe
ZERO_DECIMAL_CURRENCIES = {
'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf'
}
class PaymentGatewayError(RuntimeError):
def __init__(self, message, *, details=''):
super().__init__(message)
self.details = details
def payment_currency():
return getattr(settings, 'PAYMENT_CURRENCY', 'NPR').upper()
def stripe_configured():
return bool(settings.STRIPE_SECRET_KEY)
def esewa_configured():
return all(
[
getattr(settings, 'ESEWA_PRODUCT_CODE', '').strip(),
getattr(settings, 'ESEWA_SECRET_KEY', '').strip(),
getattr(settings, 'ESEWA_FORM_URL', '').strip(),
getattr(settings, 'ESEWA_STATUS_URL', '').strip(),
]
)
def khalti_configured():
return all(
[
getattr(settings, 'KHALTI_SECRET_KEY', '').strip(),
getattr(settings, 'KHALTI_INITIATE_URL', '').strip(),
getattr(settings, 'KHALTI_LOOKUP_URL', '').strip(),
]
)
def stripe_value(obj, key, default=None):
if obj is None:
return default
if isinstance(obj, dict):
return obj.get(key, default)
getter = getattr(obj, 'get', None)
if callable(getter):
return getter(key, default)
return getattr(obj, key, default)
def _public_base_url(request):
if settings.PUBLIC_APP_URL:
return settings.PUBLIC_APP_URL
forwarded_proto = request.META.get('HTTP_X_FORWARDED_PROTO', '').split(',')[0].strip()
scheme = forwarded_proto or request.scheme or 'http'
host = request.get_host()
if host.endswith('.flatlogic.app'):
scheme = 'https'
return f'{scheme}://{host}'
def absolute_url(request, path):
return urljoin(f"{_public_base_url(request)}/", path.lstrip('/'))
def _format_amount(amount):
value = Decimal(amount).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
return format(value, 'f')
def _to_minor_units(amount, currency):
value = Decimal(amount)
if currency.lower() in ZERO_DECIMAL_CURRENCIES:
return int(value.quantize(Decimal('1'), rounding=ROUND_HALF_UP))
return int((value * Decimal('100')).quantize(Decimal('1'), rounding=ROUND_HALF_UP))
def _configure_stripe():
stripe.api_key = settings.STRIPE_SECRET_KEY
def _line_items_for_order(order):
currency = settings.STRIPE_CURRENCY.lower()
line_items = []
for item in order.items.select_related('product').all():
line_items.append(
{
'price_data': {
'currency': currency,
'unit_amount': _to_minor_units(item.price, currency),
'product_data': {
'name': item.product.name,
'description': item.product.description[:500],
},
},
'quantity': int(item.quantity),
}
)
return line_items
def create_checkout_session(request, order):
if not stripe_configured():
raise RuntimeError('Stripe is not configured.')
_configure_stripe()
success_path = reverse('success')
cancel_path = reverse('payment', kwargs={'order_id': order.id})
success_url = f"{absolute_url(request, success_path)}?order_id={order.id}&session_id={{CHECKOUT_SESSION_ID}}"
cancel_url = f"{absolute_url(request, cancel_path)}?cancelled=1"
payload = {
'mode': 'payment',
'line_items': _line_items_for_order(order),
'success_url': success_url,
'cancel_url': cancel_url,
'client_reference_id': str(order.id),
'metadata': {
'order_id': str(order.id),
'user_id': str(order.user_id),
},
'payment_method_types': ['card'],
'billing_address_collection': 'auto',
'submit_type': 'pay',
}
if order.user.email:
payload['customer_email'] = order.user.email
return stripe.checkout.Session.create(**payload)
def retrieve_checkout_session(session_id):
if not stripe_configured():
raise RuntimeError('Stripe is not configured.')
_configure_stripe()
return stripe.checkout.Session.retrieve(session_id)
def construct_webhook_event(payload, signature):
if not stripe_configured() or not settings.STRIPE_WEBHOOK_SECRET:
raise RuntimeError('Stripe webhook is not configured.')
_configure_stripe()
return stripe.Webhook.construct_event(payload, signature, settings.STRIPE_WEBHOOK_SECRET)
def _extract_error_message(body):
try:
payload = json.loads(body)
except json.JSONDecodeError:
return (body or 'Unknown gateway error.').strip()[:300]
if isinstance(payload, dict):
if payload.get('detail'):
return str(payload['detail'])
if payload.get('error_message'):
return str(payload['error_message'])
for key, value in payload.items():
if isinstance(value, list) and value:
return f"{key}: {value[0]}"
return json.dumps(payload)[:300]
return str(payload)[:300]
def _json_request(url, *, method='GET', payload=None, headers=None, timeout=20):
request_headers = {
'Accept': 'application/json',
}
data = None
if payload is not None:
data = json.dumps(payload).encode('utf-8')
request_headers['Content-Type'] = 'application/json'
if headers:
request_headers.update(headers)
request = Request(url, data=data, headers=request_headers, method=method)
try:
with urlopen(request, timeout=timeout) as response:
body = response.read().decode('utf-8')
except HTTPError as exc:
error_body = exc.read().decode('utf-8', errors='replace')
raise PaymentGatewayError(
_extract_error_message(error_body) or f'Gateway request failed with HTTP {exc.code}.',
details=error_body,
) from exc
except URLError as exc:
raise PaymentGatewayError('Gateway request could not be completed.', details=str(exc.reason)) from exc
if not body:
return {}
try:
return json.loads(body)
except json.JSONDecodeError as exc:
raise PaymentGatewayError('Gateway returned an invalid response.', details=body[:500]) from exc
def _signed_field_names_list(signed_field_names):
return [field.strip() for field in str(signed_field_names).split(',') if field and field.strip()]
def _signed_message(payload, signed_field_names):
parts = []
for field_name in _signed_field_names_list(signed_field_names):
if field_name not in payload:
raise PaymentGatewayError(f'Missing signed field: {field_name}')
parts.append(f'{field_name}={payload[field_name]}')
return ','.join(parts)
def _hmac_sha256_base64(secret_key, message):
digest = hmac.new(secret_key.encode('utf-8'), message.encode('utf-8'), hashlib.sha256).digest()
return base64.b64encode(digest).decode('utf-8')
def _first_present(payload, *keys):
for key in keys:
value = payload.get(key)
if value not in (None, ''):
return value
return ''
def build_esewa_redirect(request, order):
if not esewa_configured():
raise PaymentGatewayError('eSewa is not configured yet.')
transaction_uuid = f"ORD-{order.id}-{uuid.uuid4().hex[:12].upper()}"
total_amount = _format_amount(order.total_price)
success_url = f"{absolute_url(request, reverse('esewa_return'))}?order_id={order.id}"
failure_url = f"{absolute_url(request, reverse('esewa_return'))}?order_id={order.id}&failed=1"
fields = {
'amount': total_amount,
'tax_amount': '0',
'total_amount': total_amount,
'transaction_uuid': transaction_uuid,
'product_code': settings.ESEWA_PRODUCT_CODE,
'product_service_charge': '0',
'product_delivery_charge': '0',
'success_url': success_url,
'failure_url': failure_url,
'signed_field_names': 'total_amount,transaction_uuid,product_code',
}
fields['signature'] = _hmac_sha256_base64(
settings.ESEWA_SECRET_KEY,
_signed_message(fields, fields['signed_field_names']),
)
return {
'action_url': settings.ESEWA_FORM_URL,
'fields': fields,
'session_id': transaction_uuid,
'currency': payment_currency(),
}
def _decode_esewa_callback(encoded_data):
if not encoded_data:
raise PaymentGatewayError('eSewa did not return any payment payload.')
padded = encoded_data + ('=' * (-len(encoded_data) % 4))
try:
raw = base64.b64decode(padded)
except Exception as exc:
raise PaymentGatewayError('eSewa returned an unreadable payment payload.') from exc
try:
payload = json.loads(raw.decode('utf-8'))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise PaymentGatewayError('eSewa returned an invalid payment payload.') from exc
if not isinstance(payload, dict):
raise PaymentGatewayError('eSewa returned an unexpected payment payload.')
return {str(key): '' if value is None else str(value) for key, value in payload.items()}
def _verify_esewa_callback_signature(payload):
signature = payload.get('signature', '').strip()
signed_field_names = payload.get('signed_field_names', '').strip()
if not signature or not signed_field_names:
raise PaymentGatewayError('eSewa callback signature is missing.')
expected_signature = _hmac_sha256_base64(
settings.ESEWA_SECRET_KEY,
_signed_message(payload, signed_field_names),
)
if not hmac.compare_digest(expected_signature, signature):
raise PaymentGatewayError('eSewa callback signature could not be verified.')
def _esewa_status_lookup(order):
params = urlencode(
{
'product_code': settings.ESEWA_PRODUCT_CODE,
'total_amount': _format_amount(order.total_price),
'transaction_uuid': order.payment_session_id,
}
)
base_url = settings.ESEWA_STATUS_URL.rstrip('/') + '/'
return _json_request(f'{base_url}?{params}')
def verify_esewa_payment(order, encoded_data):
if not esewa_configured():
raise PaymentGatewayError('eSewa is not configured yet.')
callback = _decode_esewa_callback(encoded_data.strip())
_verify_esewa_callback_signature(callback)
transaction_uuid = callback.get('transaction_uuid', '').strip()
if not transaction_uuid:
raise PaymentGatewayError('eSewa did not return a transaction id.')
if transaction_uuid != (order.payment_session_id or '').strip():
raise PaymentGatewayError('The returned eSewa session did not match the latest payment attempt for this order.')
callback_total = _format_amount(callback.get('total_amount', order.total_price))
order_total = _format_amount(order.total_price)
if callback_total != order_total:
raise PaymentGatewayError('The returned eSewa amount did not match this order total.')
callback_product_code = callback.get('product_code', '').strip()
if callback_product_code and callback_product_code != settings.ESEWA_PRODUCT_CODE:
raise PaymentGatewayError('The returned eSewa product code did not match the configured merchant code.')
status_response = _esewa_status_lookup(order)
if status_response.get('error_message'):
raise PaymentGatewayError(f"eSewa status lookup failed: {status_response['error_message']}")
gateway_status = str(status_response.get('status', '')).upper().strip()
if not gateway_status:
raise PaymentGatewayError('eSewa did not return a payment status for this transaction.')
returned_total = _first_present(status_response, 'total_amount', 'totalAmount')
if returned_total and _format_amount(returned_total) != order_total:
raise PaymentGatewayError('The verified eSewa amount did not match this order total.')
returned_product_code = str(_first_present(status_response, 'product_code', 'productCode', 'scd')).strip()
if returned_product_code and returned_product_code != settings.ESEWA_PRODUCT_CODE:
raise PaymentGatewayError('The verified eSewa merchant code did not match this order.')
reference = str(_first_present(status_response, 'ref_id', 'refId') or callback.get('transaction_code') or transaction_uuid).strip()
if gateway_status == 'COMPLETE':
local_status = 'Paid'
message = 'eSewa payment verified successfully.'
elif gateway_status in {'PENDING', 'AMBIGUOUS'}:
local_status = 'Pending'
message = 'eSewa is still processing this payment.'
else:
local_status = 'Failed'
message = f'eSewa returned {gateway_status} for this payment.'
return {
'status': local_status,
'gateway_status': gateway_status,
'reference': reference,
'session_id': transaction_uuid,
'currency': payment_currency(),
'message': message,
}
def initiate_khalti_payment(request, order):
if not khalti_configured():
raise PaymentGatewayError('Khalti is not configured yet.')
amount_in_paisa = _to_minor_units(order.total_price, payment_currency())
if amount_in_paisa < 1000:
raise PaymentGatewayError('Khalti requires a minimum payment amount of Rs. 10.')
purchase_order_id = f"ORDER-{order.id}-{uuid.uuid4().hex[:8].upper()}"
payload = {
'return_url': f"{absolute_url(request, reverse('khalti_return'))}?order_id={order.id}",
'website_url': _public_base_url(request),
'amount': amount_in_paisa,
'purchase_order_id': purchase_order_id,
'purchase_order_name': f'Order #{order.id}',
}
customer_info = {
'name': order.full_name or order.user.get_full_name() or order.user.username,
'phone': order.phone,
}
if order.user.email:
customer_info['email'] = order.user.email
payload['customer_info'] = {key: value for key, value in customer_info.items() if value}
response = _json_request(
settings.KHALTI_INITIATE_URL,
method='POST',
payload=payload,
headers={'Authorization': f'key {settings.KHALTI_SECRET_KEY}'},
)
payment_url = str(response.get('payment_url', '')).strip()
pidx = str(response.get('pidx', '')).strip()
if not payment_url or not pidx:
raise PaymentGatewayError('Khalti did not return a payment URL for this order.')
return {
'payment_url': payment_url,
'session_id': pidx,
'reference': purchase_order_id,
'currency': payment_currency(),
}
def _khalti_lookup(pidx):
return _json_request(
settings.KHALTI_LOOKUP_URL,
method='POST',
payload={'pidx': pidx},
headers={'Authorization': f'key {settings.KHALTI_SECRET_KEY}'},
)
def verify_khalti_payment(order, pidx):
if not khalti_configured():
raise PaymentGatewayError('Khalti is not configured yet.')
normalized_pidx = (pidx or '').strip()
if not normalized_pidx:
raise PaymentGatewayError('Khalti did not return a payment session id.')
if normalized_pidx != (order.payment_session_id or '').strip():
raise PaymentGatewayError('The returned Khalti session did not match the latest payment attempt for this order.')
lookup = _khalti_lookup(normalized_pidx)
gateway_status = str(lookup.get('status', '')).strip()
if not gateway_status:
raise PaymentGatewayError('Khalti did not return a payment status for this transaction.')
returned_amount = lookup.get('total_amount')
if returned_amount not in (None, ''):
try:
returned_amount_value = int(returned_amount)
except (TypeError, ValueError) as exc:
raise PaymentGatewayError('Khalti returned an invalid payment amount.') from exc
expected_amount = _to_minor_units(order.total_price, payment_currency())
if returned_amount_value != expected_amount:
raise PaymentGatewayError('The returned Khalti amount did not match this order total.')
if order.payment_reference:
returned_order_reference = str(lookup.get('purchase_order_id', '')).strip()
if returned_order_reference and returned_order_reference != order.payment_reference:
raise PaymentGatewayError('The returned Khalti purchase order id did not match this order.')
refunded = bool(lookup.get('refunded'))
transaction_reference = str(lookup.get('transaction_id') or normalized_pidx).strip()
if gateway_status == 'Completed' and not refunded:
local_status = 'Paid'
message = 'Khalti payment verified successfully.'
elif gateway_status in {'Pending', 'Initiated'}:
local_status = 'Pending'
message = 'Khalti is still processing this payment.'
else:
local_status = 'Failed'
message = f'Khalti returned {gateway_status} for this payment.'
return {
'status': local_status,
'gateway_status': gateway_status,
'reference': transaction_reference,
'session_id': normalized_pidx,
'currency': payment_currency(),
'message': message,
}

View File

@ -1,3 +1,282 @@
from django.test import TestCase
# Create your tests here.
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from orders.models import Order, OrderItem
from products.models import Product
User = get_user_model()
SAMPLE_GIF = bytes.fromhex(
'47494638396101000100800000000000ffffff21f90401000000002c00000000010001000002024401003b'
)
class OrderPaymentFlowTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='password123', email='alice@example.com')
self.client.force_login(self.user)
image = SimpleUploadedFile('product.gif', SAMPLE_GIF, content_type='image/gif')
self.product = Product.objects.create(
name='Wireless Mouse',
description='A test product for order payments.',
price='1250.00',
listing_type='sale',
image=image,
category='Electronics',
stock=8,
)
self.order = Order.objects.create(
user=self.user,
total_price='1250.00',
full_name='Alice Example',
phone='+9779800000000',
address='Kathmandu',
)
OrderItem.objects.create(order=self.order, product=self.product, quantity=1, price='1250.00')
def test_payment_page_does_not_fake_pay_order(self):
response = self.client.get(reverse('payment', args=[self.order.id]))
self.assertEqual(response.status_code, 200)
self.order.refresh_from_db()
self.assertEqual(self.order.payment_status, 'Unpaid')
self.assertEqual(self.order.status, 'Pending')
@override_settings(
PAYMENT_CURRENCY='NPR',
ESEWA_SANDBOX=True,
ESEWA_PRODUCT_CODE='EPAYTEST',
ESEWA_SECRET_KEY='secret-key',
ESEWA_FORM_URL='https://esewa.test/form',
ESEWA_STATUS_URL='https://esewa.test/status/',
)
def test_posting_esewa_returns_redirect_form_and_sets_pending_state(self):
response = self.client.post(reverse('payment', args=[self.order.id]), {'payment': 'esewa'})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'order/payment_redirect.html')
self.assertContains(response, 'https://esewa.test/form')
self.order.refresh_from_db()
self.assertEqual(self.order.payment_provider, 'esewa')
self.assertEqual(self.order.payment_method, 'eSewa')
self.assertEqual(self.order.payment_status, 'Pending')
self.assertEqual(self.order.payment_currency, 'NPR')
self.assertTrue(self.order.payment_session_id.startswith(f'ORD-{self.order.id}-'))
self.assertContains(response, self.order.payment_session_id)
@override_settings(PAYMENT_CURRENCY='NPR')
@patch('orders.views.verify_esewa_payment')
def test_esewa_return_marks_order_paid_only_after_verified_status(self, mock_verify_esewa_payment):
self.order.payment_method = 'eSewa'
self.order.payment_provider = 'esewa'
self.order.payment_status = 'Pending'
self.order.payment_session_id = 'ORD-1-ABC123'
self.order.payment_currency = 'NPR'
self.order.save()
mock_verify_esewa_payment.return_value = {
'status': 'Paid',
'reference': 'ESEWA-REF-123',
'session_id': 'ORD-1-ABC123',
'currency': 'NPR',
'message': 'eSewa payment verified successfully.',
}
response = self.client.get(reverse('esewa_return') + f'?order_id={self.order.id}&data=dummy')
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], reverse('success') + f'?order_id={self.order.id}')
self.order.refresh_from_db()
self.assertEqual(self.order.payment_status, 'Paid')
self.assertEqual(self.order.status, 'Paid')
self.assertEqual(self.order.payment_reference, 'ESEWA-REF-123')
self.assertEqual(self.order.payment_provider, 'esewa')
self.assertIsNotNone(self.order.paid_at)
@override_settings(
PAYMENT_CURRENCY='NPR',
KHALTI_SECRET_KEY='test_secret',
KHALTI_INITIATE_URL='https://khalti.test/initiate/',
KHALTI_LOOKUP_URL='https://khalti.test/lookup/',
)
@patch('orders.views.initiate_khalti_payment')
def test_posting_khalti_redirects_to_payment_url(self, mock_initiate_khalti_payment):
mock_initiate_khalti_payment.return_value = {
'payment_url': 'https://pay.khalti.test/session/123',
'session_id': 'pidx_test_123',
'reference': 'ORDER-1-TEST123',
'currency': 'NPR',
}
response = self.client.post(reverse('payment', args=[self.order.id]), {'payment': 'khalti'})
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], 'https://pay.khalti.test/session/123')
self.order.refresh_from_db()
self.assertEqual(self.order.payment_provider, 'khalti')
self.assertEqual(self.order.payment_method, 'Khalti')
self.assertEqual(self.order.payment_status, 'Pending')
self.assertEqual(self.order.payment_session_id, 'pidx_test_123')
self.assertEqual(self.order.payment_reference, 'ORDER-1-TEST123')
@override_settings(PAYMENT_CURRENCY='NPR')
@patch('orders.views.verify_khalti_payment')
def test_khalti_return_marks_order_paid_only_after_verified_lookup(self, mock_verify_khalti_payment):
self.order.payment_method = 'Khalti'
self.order.payment_provider = 'khalti'
self.order.payment_status = 'Pending'
self.order.payment_session_id = 'pidx_test_123'
self.order.payment_reference = 'ORDER-1-TEST123'
self.order.payment_currency = 'NPR'
self.order.save()
mock_verify_khalti_payment.return_value = {
'status': 'Paid',
'reference': 'TXN-456',
'session_id': 'pidx_test_123',
'currency': 'NPR',
'message': 'Khalti payment verified successfully.',
}
response = self.client.get(reverse('khalti_return') + f'?order_id={self.order.id}&pidx=pidx_test_123')
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], reverse('success') + f'?order_id={self.order.id}')
self.order.refresh_from_db()
self.assertEqual(self.order.payment_status, 'Paid')
self.assertEqual(self.order.status, 'Paid')
self.assertEqual(self.order.payment_reference, 'TXN-456')
self.assertEqual(self.order.payment_provider, 'khalti')
self.assertIsNotNone(self.order.paid_at)
def test_cash_on_delivery_does_not_mark_order_as_paid(self):
response = self.client.post(reverse('payment', args=[self.order.id]), {'payment': 'cod'})
self.assertEqual(response.status_code, 302)
self.order.refresh_from_db()
self.assertEqual(self.order.payment_method, 'Cash on Delivery')
self.assertEqual(self.order.payment_status, 'Pending')
self.assertEqual(self.order.status, 'Pending')
class CheckoutLocationFlowTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='buyer', password='password123', email='buyer@example.com')
self.user.profile.phone = '+9779811111111'
self.user.profile.location_label = 'Baneshwor, Kathmandu'
self.user.profile.default_address = 'Old Baneshwor Chowk\nKathmandu'
self.user.profile.latitude = '27.693400'
self.user.profile.longitude = '85.335000'
self.user.profile.location_accuracy_m = '20.50'
self.user.profile.save()
image = SimpleUploadedFile('checkout.gif', SAMPLE_GIF, content_type='image/gif')
self.product = Product.objects.create(
name='Smart Watch',
description='Checkout location test product.',
price='2500.00',
listing_type='sale',
image=image,
category='Electronics',
stock=5,
)
self.client.force_login(self.user)
session = self.client.session
session['cart'] = {str(self.product.id): 1}
session.save()
def test_checkout_prefills_saved_profile_delivery_details(self):
response = self.client.get(reverse('checkout'))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['delivery']['phone'], '+9779811111111')
self.assertEqual(response.context['delivery']['location_label'], 'Baneshwor, Kathmandu')
self.assertEqual(response.context['delivery']['address'], 'Old Baneshwor Chowk\nKathmandu')
self.assertEqual(response.context['delivery']['latitude'], '27.693400')
self.assertEqual(response.context['delivery']['longitude'], '85.335000')
def test_checkout_saves_order_delivery_snapshot_and_updates_profile(self):
response = self.client.post(
reverse('checkout'),
{
'full_name': 'Buyer Example',
'phone': '+9779801234567',
'location_label': 'Lalitpur, Kupondole',
'address': 'Kupondole Height\nLalitpur',
'delivery_notes': 'Call before reaching the gate',
'latitude': '27.685100',
'longitude': '85.316700',
'location_accuracy_m': '12.75',
'save_as_default': 'on',
},
)
order = Order.objects.get(user=self.user)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], reverse('payment', args=[order.id]))
self.assertEqual(order.phone, '+9779801234567')
self.assertEqual(order.location_label, 'Lalitpur, Kupondole')
self.assertEqual(order.delivery_notes, 'Call before reaching the gate')
self.assertIsNotNone(order.latitude)
self.assertIsNotNone(order.longitude)
self.user.profile.refresh_from_db()
self.assertEqual(self.user.profile.phone, '+9779801234567')
self.assertEqual(self.user.profile.location_label, 'Lalitpur, Kupondole')
self.assertEqual(self.user.profile.default_address, 'Kupondole Height\nLalitpur')
def test_checkout_can_prefill_from_recent_delivery_shortcut(self):
recent_order = Order.objects.create(
user=self.user,
total_price='2500.00',
status='Delivered',
payment_status='Paid',
payment_method='Cash on Delivery',
full_name='Buyer Example',
phone='+9779809999999',
address='Naxal Chowk\nKathmandu',
location_label='Naxal, Kathmandu',
latitude='27.717300',
longitude='85.331900',
location_accuracy_m='11.25',
delivery_notes='Ring the bell once',
)
response = self.client.get(reverse('checkout') + f'?delivery_shortcut=order-{recent_order.id}')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['delivery']['phone'], '+9779809999999')
self.assertEqual(response.context['delivery']['location_label'], 'Naxal, Kathmandu')
self.assertEqual(response.context['delivery']['address'], 'Naxal Chowk\nKathmandu')
self.assertEqual(response.context['delivery']['delivery_notes'], 'Ring the bell once')
self.assertEqual(response.context['delivery']['selected_shortcut_id'], f'order-{recent_order.id}')
self.assertTrue(any(shortcut['selected'] for shortcut in response.context['delivery_shortcuts']))
def test_checkout_save_as_default_clears_stale_profile_gps_when_manual_address_has_no_coordinates(self):
response = self.client.post(
reverse('checkout'),
{
'full_name': 'Buyer Example',
'phone': '+9779801234567',
'location_label': 'Bhaktapur Durbar Area',
'address': 'Taumadhi Square\nBhaktapur',
'delivery_notes': 'Call after arrival',
'save_as_default': 'on',
},
)
order = Order.objects.get(user=self.user)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], reverse('payment', args=[order.id]))
self.user.profile.refresh_from_db()
self.assertEqual(self.user.profile.default_address, 'Taumadhi Square\nBhaktapur')
self.assertIsNone(self.user.profile.latitude)
self.assertIsNone(self.user.profile.longitude)
self.assertIsNone(self.user.profile.location_accuracy_m)
self.assertIsNone(self.user.profile.location_updated_at)

View File

@ -1,11 +1,15 @@
from django.urls import path
from . import views
urlpatterns = [
path('checkout/', views.checkout, name='checkout'),
path('payment/<int:order_id>/', views.payment_page, name='payment'),
path('payment/<int:order_id>/<slug:gateway>/', views.payment_gateway, name='payment_gateway'),
path('success/', views.success, name='success'),
path('my-orders/', views.my_orders, name='my_orders'),
path('order/<int:order_id>/', views.order_detail, name='order_detail'),
]
from django.urls import path
from . import views
urlpatterns = [
path('checkout/', views.checkout, name='checkout'),
path('payment/<int:order_id>/', views.payment_page, name='payment'),
path('payment/<int:order_id>/<slug:gateway>/', views.payment_gateway, name='payment_gateway'),
path('payment/return/esewa/', views.esewa_return, name='esewa_return'),
path('payment/return/khalti/', views.khalti_return, name='khalti_return'),
path('payment/webhooks/stripe/', views.stripe_webhook, name='stripe_webhook'),
path('success/', views.success, name='success'),
path('my-orders/', views.my_orders, name='my_orders'),
path('order/<int:order_id>/', views.order_detail, name='order_detail'),
]

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -1,5 +1,6 @@
from django.apps import AppConfig
class ProductsConfig(AppConfig):
name = 'products'
from django.apps import AppConfig
class ProductsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'products'

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.14 on 2026-05-20 10:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0007_alter_product_category_alter_product_listing_type_and_more'),
]
operations = [
migrations.AlterField(
model_name='product',
name='category',
field=models.CharField(choices=[('Electronics', 'Electronics'), ('Mobiles', 'Mobiles'), ('Fashion', 'Fashion'), ('Home', 'Home'), ('Beauty', 'Beauty'), ('Sports', 'Sports'), ('Books', 'Books'), ('Groceries', 'Groceries'), ('Pets', 'Pets'), ('Tools', 'Tools'), ('Office', 'Office'), ('Kitchen', 'Kitchen'), ('Travel', 'Travel'), ('Automotive', 'Automotive'), ('Garden', 'Garden'), ('Party', 'Party'), ('General', 'General')], default='General', max_length=100),
),
]

View File

@ -1,3 +1,5 @@
from decimal import Decimal
from django.conf import settings
from django.db import models
@ -64,6 +66,13 @@ class Product(models.Model):
def savings(self):
if self.discount_price and self.discount_price < self.price:
return self.price - self.discount_price
return Decimal('0')
@property
def discount_percent(self):
if self.discount_price and self.discount_price < self.price and self.price:
percent = ((self.price - self.discount_price) / self.price) * Decimal('100')
return int(percent.quantize(Decimal('1')))
return 0
@property

View File

@ -1,11 +1,18 @@
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.db.models import Avg, Q
from django.db.models.functions import Coalesce
from django.shortcuts import get_object_or_404, redirect, render
from cart.views import add_to_cart
from .models import Product, WishlistItem
SORT_LABELS = {
'newest': 'Newest first',
'rating': 'Top rated',
'price_asc': 'Price low to high',
'price_desc': 'Price high to low',
}
def _normalized_categories():
categories = [choice[0] for choice in Product.CATEGORY_CHOICES]
@ -54,6 +61,18 @@ def product_list(request, category=None):
if request.user.is_authenticated:
wishlist_ids = set(WishlistItem.objects.filter(user=request.user).values_list('product_id', flat=True))
active_filters = []
if query:
active_filters.append(f'Search: “{query}')
if selected_category:
active_filters.append(f'Category: {selected_category}')
if selected_featured:
active_filters.append('Featured only')
if sort:
active_filters.append(f'Sort: {SORT_LABELS.get(sort, sort)}')
aggregates = products.aggregate(avg_rating=Avg('rating'))
return render(
request,
'products/product_list.html',
@ -68,6 +87,10 @@ def product_list(request, category=None):
'products_count': products.count(),
'featured_count': products.filter(featured=True).count(),
'in_stock_count': products.filter(stock__gt=0).count(),
'discounted_count': products.filter(discount_price__isnull=False).count(),
'avg_rating': aggregates.get('avg_rating') or 0,
'active_filters': active_filters,
'has_active_filters': bool(active_filters),
},
)
@ -115,4 +138,3 @@ def move_wishlist_to_cart(request, product_id):
def wishlist_view(request):
wishlist_items = WishlistItem.objects.filter(user=request.user).select_related('product')
return render(request, 'products/wishlist.html', {'wishlist_items': wishlist_items})

View File

@ -1,131 +1,266 @@
.auth-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 76vh;
padding: 24px;
}
.auth-box {
background: #fff;
border: 1px solid #dbe4f8;
border-radius: 24px;
padding: 36px;
width: 100%;
max-width: 420px;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08);
}
.auth-box h2 {
font-size: 2rem;
margin-bottom: 18px;
color: #0f172a;
text-align: center;
}
.auth-form {
margin-top: 24px;
display: grid;
gap: 18px;
}
.form-group {
display: grid;
gap: 8px;
}
.form-group label {
font-weight: 700;
color: #334155;
}
.form-group input {
width: 100%;
padding: 14px 16px;
border: 1px solid #cbd5e1;
border-radius: 14px;
background: #f8fbff;
color: #0f172a;
font-size: 1rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.12);
}
.form-group input::placeholder {
color: #94a3b8;
}
.form-group small {
color: #64748b;
font-size: 0.85rem;
}
.btn-primary {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #2874f0 0%, #3b82f6 100%);
color: #fff;
border: none;
border-radius: 14px;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
margin-top: 10px;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 18px 30px rgba(40, 116, 240, 0.24);
}
.alert {
padding: 14px 16px;
border-radius: 14px;
margin-bottom: 12px;
font-weight: 500;
}
.alert-success {
background: #ecfdf5;
border: 1px solid #34d399;
color: #065f46;
}
.alert-error {
background: #fef2f2;
border: 1px solid #ef4444;
color: #b91c1c;
}
.auth-link {
text-align: center;
margin-top: 18px;
color: #475569;
}
.auth-link a {
color: #2563eb;
text-decoration: none;
font-weight: 700;
}
.auth-link a:hover {
text-decoration: underline;
}
/* Responsive */
@media (max-width: 480px) {
.auth-box {
padding: 24px;
}
.auth-box h2 {
font-size: 1.6rem;
}
}
.auth-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 76vh;
padding: 24px;
}
.auth-container--wide {
align-items: flex-start;
}
.auth-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 24px;
padding: 36px;
width: 100%;
max-width: 420px;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08);
}
.auth-box--wide {
max-width: 860px;
}
.auth-copy-block {
display: grid;
gap: 8px;
}
.auth-copy-block p {
color: var(--muted);
line-height: 1.7;
}
.auth-box h2 {
font-size: 2rem;
margin-bottom: 4px;
color: var(--text);
}
.auth-form {
margin-top: 24px;
display: grid;
gap: 18px;
}
.auth-form--wide {
gap: 20px;
}
.auth-form-grid {
gap: 18px;
}
.form-group {
display: grid;
gap: 8px;
}
.form-group--full {
grid-column: 1 / -1;
}
.form-group label {
font-weight: 700;
color: var(--text);
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 14px 16px;
border: 1px solid #cbd5e1;
border-radius: 14px;
background: #f8fbff;
color: #0f172a;
font-size: 1rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.form-group textarea {
min-height: 110px;
resize: vertical;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.12);
background: #fff;
}
.form-group input::placeholder,
.form-group textarea::placeholder {
color: #94a3b8;
}
.form-group small,
.field-note {
color: #64748b;
font-size: 0.85rem;
line-height: 1.6;
}
.field-errors {
color: #b91c1c;
font-size: 0.85rem;
}
.field-errors ul {
margin: 0;
padding-left: 18px;
}
.form-group--checkbox {
background: var(--surface-soft);
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px 18px;
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 10px;
font-weight: 700;
cursor: pointer;
}
.checkbox-label input {
width: 18px;
height: 18px;
}
.btn-primary {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #2874f0 0%, #3b82f6 100%);
color: #fff;
border: none;
border-radius: 14px;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
margin-top: 4px;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 18px 30px rgba(40, 116, 240, 0.24);
}
.alert {
padding: 14px 16px;
border-radius: 14px;
margin-bottom: 12px;
font-weight: 500;
}
.alert-success {
background: #ecfdf5;
border: 1px solid #34d399;
color: #065f46;
}
.alert-error {
background: #fef2f2;
border: 1px solid #ef4444;
color: #b91c1c;
}
.auth-link {
text-align: center;
margin-top: 18px;
color: #475569;
}
.auth-link a {
color: #2563eb;
text-decoration: none;
font-weight: 700;
}
.auth-link a:hover {
text-decoration: underline;
}
body.theme-dark .auth-box,
body.theme-dark .form-group--checkbox {
background: var(--surface);
border-color: var(--border);
}
body.theme-dark .form-group input,
body.theme-dark .form-group textarea,
body.theme-dark .form-group select {
background: var(--surface-soft);
border-color: var(--border);
color: var(--text);
}
body.theme-dark .form-group input:focus,
body.theme-dark .form-group textarea:focus,
body.theme-dark .form-group select:focus {
background: var(--surface);
}
body.theme-dark .auth-link,
body.theme-dark .field-note,
body.theme-dark .form-group small,
body.theme-dark .auth-copy-block p {
color: var(--muted);
}
body.theme-dark .alert-success {
background: rgba(22, 163, 74, 0.12);
border-color: rgba(74, 222, 128, 0.32);
color: #bbf7d0;
}
body.theme-dark .alert-error {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(248, 113, 113, 0.3);
color: #fecaca;
}
@media (max-width: 768px) {
.auth-container {
padding: 16px;
}
.auth-box {
padding: 28px 22px;
}
.auth-box h2 {
font-size: 1.8rem;
}
}
@media (max-width: 480px) {
.auth-box {
padding: 22px 16px;
border-radius: 20px;
}
.auth-form,
.auth-form--wide {
gap: 16px;
}
.checkbox-label {
align-items: flex-start;
}
.btn-primary,
.location-card .btn {
width: 100%;
}
}

View File

@ -274,3 +274,199 @@
.top-category-bar { padding: 10px 16px; }
.top-navbar { padding: 12px 16px; }
}
.hero-metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.hero-metric-card {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 18px;
padding: 16px 18px;
display: grid;
gap: 6px;
}
.hero-metric-card strong {
font-size: 1.45rem;
}
.hero-metric-card span {
color: rgba(255, 255, 255, 0.82);
font-size: 0.92rem;
}
.market-pill {
display: grid;
gap: 4px;
}
.market-pill strong {
font-size: 1rem;
}
.market-pill span {
color: var(--muted);
font-size: 0.94rem;
font-weight: 600;
}
.spotlight-shell,
.experience-shell,
.quick-action-shell {
margin-top: 10px;
}
.spotlight-grid,
.experience-grid,
.quick-action-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.spotlight-card,
.experience-card,
.quick-action-card {
background: #fff;
border: 1px solid #dbe4f8;
border-radius: 22px;
padding: 22px;
box-shadow: var(--shadow-soft);
display: grid;
gap: 12px;
}
.spotlight-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.spotlight-kicker {
color: #2563eb;
font-size: 0.82rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.spotlight-pill,
.experience-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
border-radius: 999px;
padding: 6px 10px;
background: #eff6ff;
color: #1d4ed8;
font-size: 0.82rem;
font-weight: 800;
}
.spotlight-card h3,
.experience-card h3,
.quick-action-card strong {
color: var(--text);
}
.spotlight-card p,
.experience-card p,
.quick-action-card p {
color: var(--muted);
line-height: 1.7;
}
.spotlight-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.spotlight-meta span {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: #f8fbff;
border: 1px solid #dbe4f8;
color: #475569;
font-size: 0.84rem;
font-weight: 700;
}
.quick-action-card {
text-decoration: none;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.quick-action-card:hover,
.spotlight-card:hover,
.experience-card:hover {
transform: translateY(-3px);
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.12);
border-color: #bfd4ff;
}
.deal-topline {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
body.theme-dark .hero {
background: linear-gradient(135deg, #1d4ed8 0%, #1e3a8a 38%, #0f172a 100%);
}
body.theme-dark .spotlight-card,
body.theme-dark .experience-card,
body.theme-dark .quick-action-card,
body.theme-dark .deal-card,
body.theme-dark .card,
body.theme-dark .market-pill {
background: var(--surface);
border-color: var(--border);
color: var(--text);
}
body.theme-dark .market-pill span,
body.theme-dark .spotlight-card p,
body.theme-dark .experience-card p,
body.theme-dark .quick-action-card p,
body.theme-dark .deal-note,
body.theme-dark .section-heading p {
color: var(--muted);
}
body.theme-dark .spotlight-meta span,
body.theme-dark .spotlight-pill,
body.theme-dark .experience-badge,
body.theme-dark .deal-badge {
background: rgba(59, 130, 246, 0.16);
border-color: rgba(96, 165, 250, 0.22);
color: #93c5fd;
}
@media (max-width: 1024px) {
.hero-metric-grid,
.spotlight-grid,
.experience-grid,
.quick-action-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.hero-metric-grid,
.spotlight-grid,
.experience-grid,
.quick-action-grid {
grid-template-columns: 1fr;
}
}

View File

@ -322,3 +322,150 @@
justify-content: flex-end;
}
}
.fk-theme-toggle {
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.1);
}
.fk-theme-toggle .theme-icon {
font-size: 1rem;
}
.fk-theme-toggle .theme-label {
font-weight: 700;
}
.fk-dropdown-menu--right {
left: auto;
right: 0;
}
body.theme-dark .fk-theme-toggle {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.16);
}
@media (max-width: 768px) {
.fk-theme-toggle {
order: 2;
}
}
.fk-location-text {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.fk-location-copy {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fk-location-link {
white-space: nowrap;
}
@media (max-width: 960px) {
.fk-top-row,
.fk-middle-row {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.fk-top-right {
font-size: 0.82rem;
}
.fk-main-actions {
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}
.fk-action-btn {
min-height: 44px;
}
}
@media (max-width: 640px) {
.fk-container {
padding: 0 12px;
}
.fk-brand-btn {
width: 100%;
justify-content: center;
}
.fk-search-bar {
min-width: 100%;
padding: 10px 12px;
}
.fk-main-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
}
.fk-action-btn,
.fk-dropdown-wrapper {
width: 100%;
}
.fk-dropdown-wrapper > .fk-action-btn,
.fk-cart-btn {
justify-content: center;
}
.fk-dropdown-menu,
.fk-dropdown-menu--right {
left: 0;
right: 0;
min-width: 100%;
}
.fk-category-bar,
.nav-scrolled .fk-category-bar {
height: 82px;
}
.fk-category-scroll,
.nav-scrolled .fk-category-scroll {
min-height: 82px;
padding: 8px 0;
}
.fk-cat-item,
.nav-scrolled .fk-cat-item {
width: 68px;
min-width: 68px;
height: 66px;
}
.fk-location-copy {
max-width: 180px;
}
}
@media (max-width: 420px) {
.fk-main-actions {
grid-template-columns: 1fr;
}
.fk-location-copy {
max-width: 120px;
}
.fk-cat-item,
.nav-scrolled .fk-cat-item {
width: 62px;
min-width: 62px;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
}
.profile-shell {
max-width: 920px;
max-width: 980px;
margin: 0 auto;
display: grid;
gap: 24px;
@ -25,11 +25,34 @@
.profile-head::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
top: 0;
left: 0;
right: 0;
height: 5px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
}
.profile-head-copy {
display: grid;
gap: 4px;
}
.profile-readiness {
display: inline-flex;
align-items: center;
gap: 10px;
margin-top: 8px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(40, 116, 240, 0.12);
color: var(--accent);
width: fit-content;
}
.profile-readiness strong {
font-size: 1rem;
}
.profile-avatar {
width: 100px;
height: 100px;
@ -60,14 +83,14 @@
.profile-head h1 {
font-size: 1.8rem;
margin-bottom: 6px;
margin-bottom: 2px;
color: var(--text);
}
.profile-head p {
color: var(--muted);
font-size: 1rem;
margin-bottom: 4px;
margin-bottom: 2px;
}
.profile-stats {
@ -111,19 +134,137 @@
box-shadow: var(--shadow-soft);
}
.profile-panel h1,
.profile-panel h2 {
margin-bottom: 16px;
font-size: 1.3rem;
color: var(--text);
}
.profile-panel h2 {
border-bottom: 2px solid var(--surface-soft);
padding-bottom: 12px;
}
.profile-section-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.profile-section-head p {
color: var(--muted);
line-height: 1.7;
}
.profile-delivery-grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 18px;
}
.delivery-details-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.delivery-detail-card {
background: var(--surface-soft);
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
display: grid;
gap: 8px;
}
.delivery-detail-card--full {
grid-column: 1 / -1;
}
.delivery-detail-card span {
color: var(--muted);
font-size: 0.88rem;
font-weight: 700;
}
.delivery-detail-card strong {
color: var(--text);
line-height: 1.6;
}
.profile-checklist {
display: grid;
gap: 10px;
}
.check-row {
padding: 14px 16px;
border-radius: 14px;
border: 1px solid var(--border);
background: var(--surface-soft);
color: var(--muted);
font-weight: 600;
}
.check-row.is-done {
border-color: rgba(22, 163, 74, 0.28);
background: rgba(22, 163, 74, 0.08);
color: #0f766e;
}
.recent-orders {
display: grid;
gap: 12px;
}
.delivery-shortcuts-list {
display: grid;
gap: 12px;
}
.delivery-shortcut-card {
display: grid;
gap: 10px;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--border);
background: var(--surface-soft);
}
.delivery-shortcut-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.delivery-shortcut-top strong {
color: var(--text);
}
.delivery-shortcut-top span,
.delivery-shortcut-meta {
color: var(--muted);
font-size: 0.9rem;
}
.delivery-shortcut-card p {
margin: 0;
line-height: 1.7;
color: var(--text);
}
.delivery-shortcut-meta {
display: flex;
gap: 8px 16px;
flex-wrap: wrap;
}
.recent-order-row {
display: grid;
grid-template-columns: 80px minmax(0, 1fr) 110px 130px;
@ -165,8 +306,11 @@
}
.profile-edit-form {
max-width: 640px;
display: grid;
gap: 18px;
}
.profile-form-grid {
gap: 16px;
}
@ -175,6 +319,10 @@
gap: 8px;
}
.profile-edit-form .form-row--full {
grid-column: 1 / -1;
}
.profile-edit-form label {
font-size: 0.95rem;
color: var(--text);
@ -216,16 +364,28 @@
box-shadow: 0 0 0 2px var(--accent-soft);
}
.profile-edit-form .form-row:last-child {
.profile-edit-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-top: 8px;
}
@media (max-width: 900px) {
.field-errors {
color: #b91c1c;
font-size: 0.85rem;
}
.field-errors ul {
margin: 0;
padding-left: 18px;
}
@media (max-width: 980px) {
.profile-delivery-grid,
.delivery-details-grid,
.recent-order-row {
grid-template-columns: 1fr 1fr;
grid-template-columns: 1fr;
}
.recent-order-row strong {
@ -245,7 +405,23 @@
padding: 24px;
}
.profile-edit-form .form-row:last-child {
.profile-readiness {
margin-left: auto;
margin-right: auto;
}
.profile-actions .btn,
.profile-edit-actions .btn,
.profile-section-head .btn {
width: 100%;
}
.delivery-shortcut-top {
flex-direction: column;
align-items: flex-start;
}
.profile-edit-actions {
flex-direction: column;
align-items: stretch;
}

View File

@ -290,3 +290,220 @@ textarea {
border-color: var(--accent);
outline: none;
}
.section-shell {
max-width: 1180px;
margin: 0 auto 30px;
}
.page-header--split {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 18px;
flex-wrap: wrap;
}
.page-header-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.form-control {
width: 100%;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.form-group {
display: grid;
gap: 8px;
}
.form-group--full {
grid-column: 1 / -1;
}
.form-group label {
font-weight: 700;
color: var(--text);
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
}
.form-group textarea {
min-height: 110px;
resize: vertical;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(40, 116, 240, 0.1);
}
.location-card {
background: var(--surface-soft);
border: 1px solid var(--border);
border-radius: 18px;
padding: 16px;
display: grid;
gap: 12px;
}
.location-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.location-status {
color: var(--muted);
line-height: 1.7;
font-size: 0.95rem;
}
.location-status.is-success {
color: #0f766e;
}
.location-status.is-error {
color: #b91c1c;
}
.checkbox-inline {
display: inline-flex;
align-items: flex-start;
gap: 10px;
cursor: pointer;
}
.checkbox-inline input {
width: 18px;
height: 18px;
margin-top: 2px;
}
.checkbox-inline--solid {
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--border);
background: var(--surface-soft);
color: var(--text);
font-weight: 600;
}
.settings-card--wide {
grid-column: 1 / -1;
}
.settings-action--stretch {
align-items: stretch;
}
.settings-location-form {
width: 100%;
display: grid;
gap: 16px;
}
.settings-form-grid {
gap: 16px;
}
.reveal {
opacity: 0;
transform: translateY(18px);
transition: opacity 0.45s ease, transform 0.45s ease;
}
.reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
body.theme-dark .page-header,
body.theme-dark .message,
body.theme-dark .empty-state,
body.theme-dark .settings-card,
body.theme-dark .form-control,
body.theme-dark .form-select,
body.theme-dark .btn-secondary,
body.theme-dark .location-card,
body.theme-dark .checkbox-inline--solid {
background: var(--surface);
border-color: var(--border);
color: var(--text);
}
body.theme-dark .message.success {
background: rgba(22, 163, 74, 0.12);
border-color: rgba(74, 222, 128, 0.32);
color: #bbf7d0;
}
body.theme-dark .message.error,
body.theme-dark .message.danger,
body.theme-dark .message.warning {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(248, 113, 113, 0.3);
color: #fecaca;
}
body.theme-dark .page-header p,
body.theme-dark .settings-card p,
body.theme-dark .settings-header p {
color: var(--muted);
}
@media (max-width: 768px) {
.page-header-actions {
width: 100%;
}
.page-header-actions .btn,
.location-card .btn {
width: 100%;
}
.form-grid,
.settings-grid {
grid-template-columns: 1fr;
}
.settings-card--wide {
grid-column: auto;
}
.settings-container {
padding: 16px;
}
.location-card-head {
flex-direction: column;
}
}

View File

@ -2,12 +2,33 @@ document.addEventListener('DOMContentLoaded', function () {
const sidebarToggle = document.querySelector('.sidebar-toggle');
const sidebarOverlay = document.querySelector('.sidebar-overlay');
const sidebarClose = document.querySelector('.sidebar-close');
const modeToggle = document.querySelector('.mode-toggle');
const modeToggles = document.querySelectorAll('.mode-toggle');
const geolocateButtons = document.querySelectorAll('[data-geolocate-btn]');
const body = document.body;
const collapseKey = 'hk_nav_collapsed';
const themeKey = 'hk_theme';
const syncThemeToggleLabels = function (dark) {
modeToggles.forEach(function (toggle) {
const icon = toggle.querySelector('.theme-icon');
const label = toggle.querySelector('.theme-label');
const text = dark ? 'Light Mode' : 'Dark Mode';
if (icon) {
icon.textContent = dark ? '☀️' : '🌙';
}
if (label) {
label.textContent = dark ? 'Light' : 'Dark';
} else {
toggle.textContent = text;
}
toggle.setAttribute('aria-label', dark ? 'Switch to light mode' : 'Switch to dark mode');
});
};
const setCollapsed = function (collapsed) {
body.classList.toggle('nav-collapsed', collapsed);
localStorage.setItem(collapseKey, collapsed ? '1' : '0');
@ -16,10 +37,7 @@ document.addEventListener('DOMContentLoaded', function () {
const setTheme = function (theme) {
const dark = theme === 'dark';
body.classList.toggle('theme-dark', dark);
if (modeToggle) {
modeToggle.textContent = dark ? 'Light' : 'Dark';
modeToggle.setAttribute('aria-label', dark ? 'Switch to light mode' : 'Switch to dark mode');
}
syncThemeToggleLabels(dark);
localStorage.setItem(themeKey, dark ? 'dark' : 'light');
};
@ -30,9 +48,11 @@ document.addEventListener('DOMContentLoaded', function () {
setCollapsed(localStorage.getItem(collapseKey) === '1');
}
if (modeToggle) {
modeToggle.addEventListener('click', function () {
setTheme(body.classList.contains('theme-dark') ? 'light' : 'dark');
if (modeToggles.length) {
modeToggles.forEach(function (toggle) {
toggle.addEventListener('click', function () {
setTheme(body.classList.contains('theme-dark') ? 'light' : 'dark');
});
});
}
@ -63,4 +83,195 @@ document.addEventListener('DOMContentLoaded', function () {
body.classList.remove('sidebar-open');
}
});
const revealElements = document.querySelectorAll('.reveal');
if (revealElements.length) {
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target);
}
});
},
{
threshold: 0.12,
rootMargin: '0px 0px -40px 0px',
}
);
revealElements.forEach(function (element) {
observer.observe(element);
});
} else {
revealElements.forEach(function (element) {
element.classList.add('is-visible');
});
}
}
const findLocationField = function (container, names) {
for (let index = 0; index < names.length; index += 1) {
const field = container.querySelector('[name="' + names[index] + '"]');
if (field) {
return field;
}
}
return null;
};
const setStatus = function (container, type, message) {
const statusNode = container.querySelector('[data-location-status]');
if (!statusNode) {
return;
}
statusNode.textContent = message;
statusNode.classList.remove('is-success', 'is-error');
if (type === 'success') {
statusNode.classList.add('is-success');
}
if (type === 'error') {
statusNode.classList.add('is-error');
}
};
const setFieldValue = function (field, value, overwriteWhenFilled) {
if (!field) {
return;
}
if (!overwriteWhenFilled && field.value) {
return;
}
field.value = value || '';
};
const buildAddressText = function (address) {
const roadLine = [address.house_number, address.road].filter(Boolean).join(' ').trim();
const areaLine = [
address.suburb,
address.neighbourhood,
address.city || address.town || address.village || address.municipality,
address.state,
address.postcode,
address.country,
]
.filter(Boolean)
.filter(function (value, index, values) {
return values.indexOf(value) === index;
})
.join(', ')
.trim();
return [roadLine, areaLine].filter(Boolean).join('\n').trim();
};
const buildLocationLabel = function (address) {
return [
address.suburb || address.neighbourhood || address.hamlet,
address.city || address.town || address.village || address.municipality,
address.state,
]
.filter(Boolean)
.filter(function (value, index, values) {
return values.indexOf(value) === index;
})
.join(', ')
.trim();
};
const reverseGeocode = async function (latitude, longitude) {
const url = 'https://nominatim.openstreetmap.org/reverse?format=jsonv2&addressdetails=1&lat=' + encodeURIComponent(latitude) + '&lon=' + encodeURIComponent(longitude);
const response = await fetch(url, {
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error('Reverse geocoding is temporarily unavailable.');
}
return response.json();
};
if (geolocateButtons.length) {
geolocateButtons.forEach(function (button) {
button.addEventListener('click', function () {
const container = button.closest('[data-location-form]') || button.closest('form') || document;
const latitudeField = findLocationField(container, ['latitude']);
const longitudeField = findLocationField(container, ['longitude']);
const accuracyField = findLocationField(container, ['location_accuracy_m']);
const labelField = findLocationField(container, ['location_label']);
const addressField = findLocationField(container, ['default_address', 'address']);
if (!navigator.geolocation) {
setStatus(container, 'error', 'Your browser does not support GPS access. You can still enter your address manually.');
return;
}
button.disabled = true;
setStatus(container, '', 'Requesting GPS access from your device…');
navigator.geolocation.getCurrentPosition(
async function (position) {
const latitude = Number(position.coords.latitude).toFixed(6);
const longitude = Number(position.coords.longitude).toFixed(6);
const accuracy = Number(position.coords.accuracy || 0).toFixed(2);
if (latitudeField) {
latitudeField.value = latitude;
}
if (longitudeField) {
longitudeField.value = longitude;
}
if (accuracyField) {
accuracyField.value = accuracy;
}
try {
const payload = await reverseGeocode(latitude, longitude);
const address = payload.address || {};
const suggestedAddress = buildAddressText(address);
const locationLabel = buildLocationLabel(address);
setFieldValue(labelField, locationLabel, false);
setFieldValue(addressField, suggestedAddress || payload.display_name || '', false);
setStatus(
container,
'success',
'GPS captured successfully. Accuracy ±' + Math.round(Number(accuracy)) + ' meters. Please review the address before saving.'
);
} catch (error) {
setStatus(
container,
'success',
'GPS captured successfully. Exact coordinates were saved, but the address suggestion could not be loaded right now.'
);
} finally {
button.disabled = false;
}
},
function (error) {
let message = 'Unable to get your location. Please enter your address manually.';
if (error.code === error.PERMISSION_DENIED) {
message = 'Location permission was denied. You can still continue with a manual address.';
} else if (error.code === error.TIMEOUT) {
message = 'Location request timed out. Please retry or continue manually.';
}
setStatus(container, 'error', message);
button.disabled = false;
},
{
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 0,
}
);
});
});
}
});

View File

@ -1,44 +1,110 @@
{% extends 'base.html' %}
{% load static %}
{% extends 'base.html' %}
{% load static %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'css/profile.css' %}?v=20260519">
<link rel="stylesheet" href="{% static 'css/profile.css' %}?v=20260520">
{% endblock %}
{% block content %}
<section class="profile-container">
<div class="profile-shell">
<div class="profile-panel">
<div class="profile-section-head">
<div>
<h1>Edit Profile</h1>
<p>Manage your account info, saved phone number, address, and GPS delivery preference.</p>
</div>
<a href="{% url 'profile' %}" class="btn btn-secondary">Back to Profile</a>
</div>
<form method="post" enctype="multipart/form-data" class="profile-edit-form" data-location-form>
{% csrf_token %}
{{ form.latitude }}
{{ form.longitude }}
{{ form.location_accuracy_m }}
{% if form.non_field_errors %}
<div class="alert-card warning">{{ form.non_field_errors }}</div>
{% endif %}
<div class="form-grid profile-form-grid">
<div class="form-row">
<label for="{{ form.first_name.id_for_label }}">First name</label>
{{ form.first_name }}
{% if form.first_name.errors %}<div class="field-errors">{{ form.first_name.errors }}</div>{% endif %}
</div>
<div class="form-row">
<label for="{{ form.last_name.id_for_label }}">Last name</label>
{{ form.last_name }}
{% if form.last_name.errors %}<div class="field-errors">{{ form.last_name.errors }}</div>{% endif %}
</div>
<div class="form-row">
<label for="{{ form.email.id_for_label }}">Email</label>
{{ form.email }}
{% if form.email.errors %}<div class="field-errors">{{ form.email.errors }}</div>{% endif %}
</div>
<div class="form-row">
<label for="{{ form.phone.id_for_label }}">Phone</label>
{{ form.phone }}
{% if form.phone.errors %}<div class="field-errors">{{ form.phone.errors }}</div>{% endif %}
</div>
<div class="form-row form-row--full">
<label for="{{ form.location_label.id_for_label }}">Delivery location label</label>
{{ form.location_label }}
{% if form.location_label.errors %}<div class="field-errors">{{ form.location_label.errors }}</div>{% else %}<small class="field-note">Shown in the navbar and used as your quick delivery area.</small>{% endif %}
</div>
<div class="form-row form-row--full">
<div class="location-card">
<div class="location-card-head">
<div>
<strong>GPS delivery pin</strong>
<p class="field-note">Refresh your exact coordinates when you move or want more accurate delivery support.</p>
</div>
<button type="button" class="btn btn-secondary small" data-geolocate-btn>Update GPS</button>
</div>
<p class="location-status" data-location-status>
{% if profile.has_precise_location %}
GPS is saved for this account. Update it anytime if your delivery point changes.
{% else %}
No exact GPS saved yet. You can still use a manual address, but GPS improves delivery accuracy.
{% endif %}
</p>
{% if profile.location_updated_at %}
<small class="field-note">Last GPS update: {{ profile.location_updated_at|date:'M d, Y H:i' }}</small>
{% endif %}
</div>
</div>
<div class="form-row form-row--full">
<label for="{{ form.default_address.id_for_label }}">Default delivery address</label>
{{ form.default_address }}
{% if form.default_address.errors %}<div class="field-errors">{{ form.default_address.errors }}</div>{% endif %}
</div>
<div class="form-row form-row--full">
<label for="{{ form.bio.id_for_label }}">Bio</label>
{{ form.bio }}
{% if form.bio.errors %}<div class="field-errors">{{ form.bio.errors }}</div>{% endif %}
</div>
<div class="form-row form-row--full">
<label for="{{ form.image.id_for_label }}">Profile picture</label>
{% if profile.image %}
<div class="current-image"><img src="{{ profile.image.url }}" alt="avatar"/></div>
{% endif %}
{{ form.image }}
{% if form.image.errors %}<div class="field-errors">{{ form.image.errors }}</div>{% endif %}
</div>
</div>
<div class="profile-edit-actions">
<button class="btn btn-primary" type="submit">Save Changes</button>
<a href="{% url 'profile' %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</section>
{% endblock %}
{% block content %}
<section class="profile-container">
<div class="profile-shell">
<h1>Edit Profile</h1>
<form method="post" enctype="multipart/form-data" class="profile-edit-form">
{% csrf_token %}
<div class="form-row">
<label>First name</label>
{{ form.first_name }}
</div>
<div class="form-row">
<label>Last name</label>
{{ form.last_name }}
</div>
<div class="form-row">
<label>Email</label>
{{ form.email }}
</div>
<div class="form-row">
<label>Profile picture</label>
{% if profile.image %}
<div class="current-image"><img src="{{ profile.image.url }}" alt="avatar"/></div>
{% endif %}
{{ form.image }}
</div>
<div class="form-row">
<label>Bio</label>
{{ form.bio }}
</div>
<div class="form-row">
<button class="btn btn-primary" type="submit">Save</button>
<a href="{% url 'profile' %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</section>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More