Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3c8e0ee36 | ||
|
|
2f63d1b7f1 |
0
.perm_test_apache
Normal file
0
.perm_test_exec
Normal file
1
.venv/bin/python
Symbolic link
@ -0,0 +1 @@
|
||||
python3
|
||||
1
.venv/bin/python3
Symbolic link
@ -0,0 +1 @@
|
||||
/usr/bin/python3
|
||||
1
.venv/bin/python3.11
Symbolic link
@ -0,0 +1 @@
|
||||
python3
|
||||
1
.venv/lib64
Symbolic link
@ -0,0 +1 @@
|
||||
lib
|
||||
5
.venv/pyvenv.cfg
Normal file
@ -0,0 +1,5 @@
|
||||
home = /usr/bin
|
||||
include-system-site-packages = false
|
||||
version = 3.11.2
|
||||
executable = /usr/bin/python3.11
|
||||
command = /usr/bin/python3 -m venv /home/ubuntu/executor/workspace/.venv
|
||||
17
db/config.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
// Generated by setup_mariadb_project.sh — edit as needed.
|
||||
define('DB_HOST', '127.0.0.1');
|
||||
define('DB_NAME', 'app_40045');
|
||||
define('DB_USER', 'app_40045');
|
||||
define('DB_PASS', '37ed550e-aefb-4c8d-a597-3c67c556d8fe');
|
||||
|
||||
function db() {
|
||||
static $pdo;
|
||||
if (!$pdo) {
|
||||
$pdo = new PDO('mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset=utf8mb4', DB_USER, DB_PASS, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
}
|
||||
return $pdo;
|
||||
}
|
||||
BIN
myproject/accounts/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
myproject/accounts/__pycache__/admin.cpython-311.pyc
Normal file
BIN
myproject/accounts/__pycache__/apps.cpython-311.pyc
Normal file
BIN
myproject/accounts/__pycache__/delivery.cpython-311.pyc
Normal file
BIN
myproject/accounts/__pycache__/forms.cpython-311.pyc
Normal file
BIN
myproject/accounts/__pycache__/models.cpython-311.pyc
Normal file
BIN
myproject/accounts/__pycache__/tests.cpython-311.pyc
Normal file
BIN
myproject/accounts/__pycache__/urls.cpython-311.pyc
Normal file
BIN
myproject/accounts/__pycache__/views.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__/__init__.cpython-311.pyc
Normal file
BIN
myproject/cart/__pycache__/admin.cpython-311.pyc
Normal file
BIN
myproject/cart/__pycache__/apps.cpython-311.pyc
Normal file
BIN
myproject/cart/__pycache__/models.cpython-311.pyc
Normal file
BIN
myproject/cart/__pycache__/tests.cpython-311.pyc
Normal file
BIN
myproject/cart/__pycache__/urls.cpython-311.pyc
Normal file
BIN
myproject/cart/__pycache__/views.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'
|
||||
|
||||
BIN
myproject/cart/migrations/__pycache__/__init__.cpython-311.pyc
Normal file
@ -120,12 +120,16 @@ def cart_view(request):
|
||||
cart = get_cart(request)
|
||||
products = []
|
||||
subtotal = Decimal('0')
|
||||
item_count = 0
|
||||
saved_amount = Decimal('0')
|
||||
|
||||
for id, qty in cart.items():
|
||||
product = get_object_or_404(Product, id=int(id))
|
||||
product.qty = qty
|
||||
product.subtotal = product.display_price * qty
|
||||
subtotal += product.subtotal
|
||||
item_count += qty
|
||||
saved_amount += Decimal(str(product.savings or 0)) * qty
|
||||
products.append(product)
|
||||
|
||||
shipping = Decimal('60') if products else Decimal('0')
|
||||
@ -155,5 +159,7 @@ def cart_view(request):
|
||||
'grand_total': grand_total,
|
||||
'coupon_code': coupon_code,
|
||||
'coupon': coupon,
|
||||
'item_count': item_count,
|
||||
'saved_amount': saved_amount,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
BIN
myproject/core/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
myproject/core/__pycache__/admin.cpython-311.pyc
Normal file
BIN
myproject/core/__pycache__/apps.cpython-311.pyc
Normal file
BIN
myproject/core/__pycache__/context_processors.cpython-311.pyc
Normal file
BIN
myproject/core/__pycache__/models.cpython-311.pyc
Normal file
BIN
myproject/core/__pycache__/tests.cpython-311.pyc
Normal file
BIN
myproject/core/__pycache__/urls.cpython-311.pyc
Normal file
BIN
myproject/core/__pycache__/views.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,
|
||||
|
||||
BIN
myproject/core/migrations/__pycache__/__init__.cpython-311.pyc
Normal file
@ -1,8 +1,11 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Avg
|
||||
from django.contrib import messages
|
||||
from django.db.models import Avg, Count, Q
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from accounts.forms import DeliveryPreferencesForm
|
||||
from accounts.models import Profile
|
||||
from products.models import Product
|
||||
|
||||
|
||||
@ -18,6 +21,26 @@ def _format_discount_offer(product):
|
||||
}
|
||||
|
||||
|
||||
def _build_category_spotlights():
|
||||
top_categories = list(
|
||||
Product.objects.exclude(category='')
|
||||
.values('category')
|
||||
.annotate(
|
||||
total=Count('id'),
|
||||
featured_total=Count('id', filter=Q(featured=True)),
|
||||
avg_rating=Avg('rating'),
|
||||
)
|
||||
.order_by('-total', 'category')[:4]
|
||||
)
|
||||
|
||||
for item in top_categories:
|
||||
category_products = Product.objects.filter(category=item['category'])
|
||||
item['avg_rating'] = item.get('avg_rating') or 0
|
||||
item['best_price'] = min((product.display_price for product in category_products), default=Decimal('0'))
|
||||
|
||||
return top_categories
|
||||
|
||||
|
||||
def home(request):
|
||||
trending_products = Product.objects.filter(featured=True).order_by('-created_at')[:4]
|
||||
if not trending_products.exists():
|
||||
@ -40,24 +63,30 @@ def home(request):
|
||||
if offer_deal:
|
||||
special_deals.append(offer_deal)
|
||||
|
||||
special_deals.append({
|
||||
'title': f'{len(discounted_products)} offers live',
|
||||
'description': 'Discounts available across top Nepali categories.',
|
||||
'note': 'Browse products with extra savings today.',
|
||||
})
|
||||
special_deals.append(
|
||||
{
|
||||
'title': f'{len(discounted_products)} offers live',
|
||||
'description': 'Discounts available across top Nepali categories.',
|
||||
'note': 'Browse products with extra savings today.',
|
||||
}
|
||||
)
|
||||
|
||||
if featured_count:
|
||||
special_deals.append({
|
||||
'title': 'Featured Seller Picks',
|
||||
'description': f'{featured_count} curated products from trusted sellers.',
|
||||
'note': 'Popular with Nepali shoppers.',
|
||||
})
|
||||
special_deals.append(
|
||||
{
|
||||
'title': 'Featured Seller Picks',
|
||||
'description': f'{featured_count} curated products from trusted sellers.',
|
||||
'note': 'Popular with Nepali shoppers.',
|
||||
}
|
||||
)
|
||||
else:
|
||||
special_deals.append({
|
||||
'title': 'Daily Deals',
|
||||
'description': 'Fresh discount offers updated every day.',
|
||||
'note': 'Check back for more savings.',
|
||||
})
|
||||
special_deals.append(
|
||||
{
|
||||
'title': 'Daily Deals',
|
||||
'description': 'Fresh discount offers updated every day.',
|
||||
'note': 'Check back for more savings.',
|
||||
}
|
||||
)
|
||||
|
||||
advertised_products = sorted(
|
||||
discounted_products,
|
||||
@ -68,7 +97,7 @@ def home(request):
|
||||
if not advertised_products:
|
||||
advertised_products = list(trending_products[:4])
|
||||
|
||||
for index, product in enumerate(advertised_products):
|
||||
for product in advertised_products:
|
||||
savings = (product.price - product.discount_price) if product.discount_price else Decimal('0')
|
||||
percent = int((savings / product.price * Decimal('100')).quantize(Decimal('1'))) if product.price else 0
|
||||
if percent >= 50:
|
||||
@ -83,6 +112,29 @@ def home(request):
|
||||
label = 'Featured Deal'
|
||||
product.deal_label = label
|
||||
|
||||
experience_highlights = [
|
||||
{
|
||||
'title': 'Verified payments',
|
||||
'description': 'Orders are only marked paid after wallet or card verification is completed on the server.',
|
||||
'badge': 'Trusted checkout',
|
||||
},
|
||||
{
|
||||
'title': 'Flexible payment flow',
|
||||
'description': 'Customers can start with cash on delivery and later switch to eSewa or Khalti before shipment.',
|
||||
'badge': 'Wallet ready',
|
||||
},
|
||||
{
|
||||
'title': 'Smart product discovery',
|
||||
'description': 'Search, categories, sorting, wishlist, and featured filters make it easier to find the right item fast.',
|
||||
'badge': 'Fast browsing',
|
||||
},
|
||||
{
|
||||
'title': 'Trackable orders',
|
||||
'description': 'Order status, payment status, and next actions are visible from one clean dashboard-style experience.',
|
||||
'badge': 'Clear visibility',
|
||||
},
|
||||
]
|
||||
|
||||
return render(
|
||||
request,
|
||||
'core/home.html',
|
||||
@ -94,6 +146,8 @@ def home(request):
|
||||
'avg_rating': aggregates.get('avg_rating') or 0,
|
||||
'special_deals': special_deals,
|
||||
'advertised_products': advertised_products,
|
||||
'category_spotlights': _build_category_spotlights(),
|
||||
'experience_highlights': experience_highlights,
|
||||
},
|
||||
)
|
||||
|
||||
@ -128,12 +182,44 @@ def landing(request):
|
||||
|
||||
|
||||
def settings_view(request):
|
||||
if request.method == 'POST':
|
||||
delivery_location = request.POST.get('delivery_location', '').strip()
|
||||
if delivery_location:
|
||||
request.session['delivery_location'] = delivery_location
|
||||
location_form = None
|
||||
guest_delivery_location = request.session.get('delivery_location', '').strip()
|
||||
profile = None
|
||||
|
||||
if request.user.is_authenticated:
|
||||
profile = getattr(request.user, 'profile', None)
|
||||
if profile is None:
|
||||
profile = Profile.objects.create(user=request.user)
|
||||
|
||||
if request.method == 'POST':
|
||||
location_form = DeliveryPreferencesForm(request.POST, instance=profile)
|
||||
if location_form.is_valid():
|
||||
profile = location_form.save()
|
||||
if profile.short_location:
|
||||
request.session['delivery_location'] = profile.short_location
|
||||
else:
|
||||
request.session.pop('delivery_location', None)
|
||||
messages.success(request, 'Delivery preferences updated successfully.')
|
||||
return redirect('settings')
|
||||
messages.error(request, 'Please correct the location details below.')
|
||||
else:
|
||||
location_form = DeliveryPreferencesForm(instance=profile)
|
||||
elif request.method == 'POST':
|
||||
guest_delivery_location = request.POST.get('delivery_location', '').strip()
|
||||
if guest_delivery_location:
|
||||
request.session['delivery_location'] = guest_delivery_location
|
||||
messages.success(request, 'Temporary delivery location saved for this browser.')
|
||||
else:
|
||||
request.session.pop('delivery_location', None)
|
||||
messages.info(request, 'Temporary delivery location cleared.')
|
||||
return redirect('settings')
|
||||
|
||||
return render(request, 'core/settings.html')
|
||||
return render(
|
||||
request,
|
||||
'core/settings.html',
|
||||
{
|
||||
'location_form': location_form,
|
||||
'guest_delivery_location': guest_delivery_location,
|
||||
'location_saved_at': getattr(profile, 'location_updated_at', None),
|
||||
},
|
||||
)
|
||||
|
||||
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 |
BIN
myproject/myproject/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
myproject/myproject/__pycache__/settings.cpython-311.pyc
Normal file
BIN
myproject/myproject/__pycache__/urls.cpython-311.pyc
Normal file
BIN
myproject/myproject/__pycache__/wsgi.cpython-311.pyc
Normal file
@ -1,69 +1,72 @@
|
||||
"""
|
||||
Django settings for myproject project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 6.0.5.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/6.0/ref/settings/
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = 'django-insecure-%)ie75*7@1xl91^h6^$d!npm$=cf7@oqcc9b&jqxqc8t!@9uj1'
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ['localhost', '127.0.0.1', '[::1]', 'testserver']
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'core', # core app for home, about, contact
|
||||
'accounts', # accounts app for user profiles
|
||||
'cart', # cart app for shopping cart functionality
|
||||
'orders', # orders app for order management
|
||||
'products', # products app for product management
|
||||
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'myproject.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / 'tempelates'], # ✅ FIX HERE
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
"""
|
||||
Django settings for myproject project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 6.0.5.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/topics/settings/
|
||||
|
||||
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'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = 'django-insecure-%)ie75*7@1xl91^h6^$d!npm$=cf7@oqcc9b&jqxqc8t!@9uj1'
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
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
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'core', # core app for home, about, contact
|
||||
'accounts', # accounts app for user profiles
|
||||
'cart', # cart app for shopping cart functionality
|
||||
'orders', # orders app for order management
|
||||
'products', # products app for product management
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'myproject.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / 'tempelates'],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
@ -73,64 +76,100 @@ TEMPLATES = [
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'myproject.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / "static",
|
||||
]
|
||||
|
||||
# Authentication Settings
|
||||
LOGIN_URL = 'login'
|
||||
LOGIN_REDIRECT_URL = 'profile'
|
||||
|
||||
WSGI_APPLICATION = 'myproject.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
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'
|
||||
|
||||