Autosave: 20260520-105030
BIN
myproject/accounts/__pycache__/delivery.cpython-311.pyc
Normal file
BIN
myproject/accounts/__pycache__/forms.cpython-311.pyc
Normal file
BIN
myproject/accounts/__pycache__/tests.cpython-311.pyc
Normal 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'
|
||||
|
||||
@ -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'
|
||||
|
||||
128
myproject/accounts/delivery.py
Normal 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,
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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})
|
||||
|
||||
BIN
myproject/cart/__pycache__/tests.cpython-311.pyc
Normal 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'
|
||||
|
||||
@ -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,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
BIN
myproject/core/__pycache__/tests.cpython-311.pyc
Normal 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,
|
||||
|
||||
@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
BIN
myproject/media/products/checkout.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/checkout_7Tq3pK9.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/checkout_Apl5oIV.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/checkout_Fu8w9sg.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/checkout_OIU3UZr.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/checkout_UwESDAP.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/checkout_XwH82Ej.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/checkout_cHT6Jqv.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/checkout_kHc70mM.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/checkout_kXmZj1w.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/checkout_swmZZ69.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/checkout_tynTK9s.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_43wNxQz.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_6lHiVJy.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_Ant52cM.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_AuUXWqH.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_ETEIG5v.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_ElMt9Bz.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_Jr0cyVq.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_LLFVOs6.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_OEdan1B.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_P2RBysy.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_QD5Pj7T.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_R2JLv1Y.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_RHmtSgn.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_RoWLVcr.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_SaeMYQz.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_bbYiWAc.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_g0hum12.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_k2IZBN8.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_qrGyMS2.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_rCx1Jzm.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_rGz8cvy.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_tjnDy5f.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/products/product_vcUB9za.gif
Normal file
|
After Width: | Height: | Size: 43 B |
BIN
myproject/media/profile_pics/user_3/samanya_you.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
myproject/media/profile_pics/user_6/logo.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
@ -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'
|
||||
|
||||
BIN
myproject/orders/__pycache__/payments.cpython-311.pyc
Normal file
BIN
myproject/orders/__pycache__/tests.cpython-311.pyc
Normal 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')
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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}"
|
||||
|
||||
497
myproject/orders/payments.py
Normal 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,
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
|
||||
BIN
myproject/products/__pycache__/tests.cpython-311.pyc
Normal 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'
|
||||
|
||||
18
myproject/products/migrations/0008_alter_product_category.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -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})
|
||||
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -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 %}
|
||||
|
||||