diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 13f71e7..255213d 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index 9998128..933112c 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index 7d3121c..cd848e1 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 97afd4a..0c44493 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index 49646aa..130daf1 100644 --- a/core/admin.py +++ b/core/admin.py @@ -6,7 +6,8 @@ from .models import ( Quotation, QuotationItem, SaleReturn, SaleReturnItem, PurchaseReturn, PurchaseReturnItem, - SystemSetting, PaymentMethod + SystemSetting, PaymentMethod, HeldSale, + LoyaltyTier, LoyaltyTransaction ) @admin.register(Category) @@ -26,7 +27,7 @@ class ProductAdmin(admin.ModelAdmin): @admin.register(Customer) class CustomerAdmin(admin.ModelAdmin): - list_display = ('name', 'phone', 'email') + list_display = ('name', 'phone', 'email', 'loyalty_points', 'loyalty_tier') search_fields = ('name', 'phone') @admin.register(Supplier) @@ -71,4 +72,18 @@ class PurchaseReturnAdmin(admin.ModelAdmin): @admin.register(SystemSetting) class SystemSettingAdmin(admin.ModelAdmin): - list_display = ('business_name', 'phone', 'email', 'vat_number') \ No newline at end of file + list_display = ('business_name', 'phone', 'email', 'vat_number') + +@admin.register(HeldSale) +class HeldSaleAdmin(admin.ModelAdmin): + list_display = ('id', 'customer', 'total_amount', 'created_at') + +@admin.register(LoyaltyTier) +class LoyaltyTierAdmin(admin.ModelAdmin): + list_display = ('name_en', 'name_ar', 'min_points', 'point_multiplier', 'discount_percentage') + +@admin.register(LoyaltyTransaction) +class LoyaltyTransactionAdmin(admin.ModelAdmin): + list_display = ('customer', 'transaction_type', 'points', 'created_at') + list_filter = ('transaction_type', 'created_at') + search_fields = ('customer__name',) diff --git a/core/migrations/0014_loyaltytier_customer_loyalty_points_and_more.py b/core/migrations/0014_loyaltytier_customer_loyalty_points_and_more.py new file mode 100644 index 0000000..2842852 --- /dev/null +++ b/core/migrations/0014_loyaltytier_customer_loyalty_points_and_more.py @@ -0,0 +1,78 @@ +# Generated by Django 5.2.7 on 2026-02-02 16:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0013_heldsale'), + ] + + operations = [ + migrations.CreateModel( + name='LoyaltyTier', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name_en', models.CharField(max_length=50, verbose_name='Name (English)')), + ('name_ar', models.CharField(max_length=50, verbose_name='Name (Arabic)')), + ('min_points', models.PositiveIntegerField(default=0, verbose_name='Minimum Points')), + ('point_multiplier', models.DecimalField(decimal_places=2, default=1.0, max_digits=4, verbose_name='Point Multiplier')), + ('discount_percentage', models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='Discount Percentage')), + ('color_code', models.CharField(default='#6c757d', max_length=20, verbose_name='Color Code')), + ], + ), + migrations.AddField( + model_name='customer', + name='loyalty_points', + field=models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Loyalty Points'), + ), + migrations.AddField( + model_name='sale', + name='loyalty_discount_amount', + field=models.DecimalField(decimal_places=3, default=0, max_digits=15, verbose_name='Loyalty Discount'), + ), + migrations.AddField( + model_name='sale', + name='loyalty_points_redeemed', + field=models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Loyalty Points Redeemed'), + ), + migrations.AddField( + model_name='systemsetting', + name='currency_per_point', + field=models.DecimalField(decimal_places=3, default=0.01, max_digits=10, verbose_name='Currency Value per Point'), + ), + migrations.AddField( + model_name='systemsetting', + name='loyalty_enabled', + field=models.BooleanField(default=False, verbose_name='Enable Loyalty System'), + ), + migrations.AddField( + model_name='systemsetting', + name='min_points_to_redeem', + field=models.PositiveIntegerField(default=100, verbose_name='Minimum Points to Redeem'), + ), + migrations.AddField( + model_name='systemsetting', + name='points_per_currency', + field=models.DecimalField(decimal_places=2, default=1.0, max_digits=10, verbose_name='Points Earned per Currency Unit'), + ), + migrations.AddField( + model_name='customer', + name='loyalty_tier', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='customers', to='core.loyaltytier'), + ), + migrations.CreateModel( + name='LoyaltyTransaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('transaction_type', models.CharField(choices=[('earned', 'Earned'), ('redeemed', 'Redeemed'), ('adjusted', 'Adjusted')], max_length=20, verbose_name='Type')), + ('points', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='Points')), + ('notes', models.TextField(blank=True, verbose_name='Notes')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='loyalty_transactions', to='core.customer')), + ('sale', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='loyalty_transactions', to='core.sale')), + ], + ), + ] diff --git a/core/migrations/0015_userprofile.py b/core/migrations/0015_userprofile.py new file mode 100644 index 0000000..f62b301 --- /dev/null +++ b/core/migrations/0015_userprofile.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.7 on 2026-02-02 16:58 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0014_loyaltytier_customer_loyalty_points_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ImageField(blank=True, null=True, upload_to='profile_pics/', verbose_name='Profile Picture')), + ('phone', models.CharField(blank=True, max_length=20, verbose_name='Phone Number')), + ('bio', models.TextField(blank=True, verbose_name='Bio')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/core/migrations/__pycache__/0014_loyaltytier_customer_loyalty_points_and_more.cpython-311.pyc b/core/migrations/__pycache__/0014_loyaltytier_customer_loyalty_points_and_more.cpython-311.pyc new file mode 100644 index 0000000..43af3a2 Binary files /dev/null and b/core/migrations/__pycache__/0014_loyaltytier_customer_loyalty_points_and_more.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0015_userprofile.cpython-311.pyc b/core/migrations/__pycache__/0015_userprofile.cpython-311.pyc new file mode 100644 index 0000000..84c92a3 Binary files /dev/null and b/core/migrations/__pycache__/0015_userprofile.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 71a240b..6d25482 100644 --- a/core/models.py +++ b/core/models.py @@ -2,6 +2,8 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from django.utils import timezone from django.contrib.auth.models import User +from django.db.models.signals import post_save +from django.dispatch import receiver class Category(models.Model): name_en = models.CharField(_("Name (English)"), max_length=100) @@ -42,15 +44,50 @@ class Product(models.Model): def __str__(self): return f"{self.name_en} ({self.sku})" +class LoyaltyTier(models.Model): + name_en = models.CharField(_("Name (English)"), max_length=50) + name_ar = models.CharField(_("Name (Arabic)"), max_length=50) + min_points = models.PositiveIntegerField(_("Minimum Points"), default=0) + point_multiplier = models.DecimalField(_("Point Multiplier"), max_digits=4, decimal_places=2, default=1.0) + discount_percentage = models.DecimalField(_("Discount Percentage"), max_digits=5, decimal_places=2, default=0) + color_code = models.CharField(_("Color Code"), max_length=20, default="#6c757d") + + def __str__(self): + return f"{self.name_en} / {self.name_ar}" + class Customer(models.Model): name = models.CharField(_("Name"), max_length=200) phone = models.CharField(_("Phone"), max_length=20, blank=True) email = models.EmailField(_("Email"), blank=True) address = models.TextField(_("Address"), blank=True) + loyalty_points = models.DecimalField(_("Loyalty Points"), max_digits=15, decimal_places=2, default=0) + loyalty_tier = models.ForeignKey(LoyaltyTier, on_delete=models.SET_NULL, null=True, blank=True, related_name="customers") def __str__(self): return self.name + def update_tier(self): + tiers = LoyaltyTier.objects.filter(min_points__lte=self.loyalty_points).order_by('-min_points') + if tiers.exists(): + self.loyalty_tier = tiers.first() + self.save() + +class LoyaltyTransaction(models.Model): + TRANSACTION_TYPES = [ + ('earned', _('Earned')), + ('redeemed', _('Redeemed')), + ('adjusted', _('Adjusted')), + ] + customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name="loyalty_transactions") + sale = models.ForeignKey('Sale', on_delete=models.SET_NULL, null=True, blank=True, related_name="loyalty_transactions") + transaction_type = models.CharField(_("Type"), max_length=20, choices=TRANSACTION_TYPES) + points = models.DecimalField(_("Points"), max_digits=15, decimal_places=2) + notes = models.TextField(_("Notes"), blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.transaction_type} {self.points} for {self.customer.name}" + class Supplier(models.Model): name = models.CharField(_("Name"), max_length=200) contact_person = models.CharField(_("Contact Person"), max_length=200, blank=True) @@ -86,6 +123,8 @@ class Sale(models.Model): paid_amount = models.DecimalField(_("Paid Amount"), max_digits=15, decimal_places=3, default=0) balance_due = models.DecimalField(_("Balance Due"), max_digits=15, decimal_places=3, default=0) discount = models.DecimalField(_("Discount"), max_digits=15, decimal_places=3, default=0) + loyalty_points_redeemed = models.DecimalField(_("Loyalty Points Redeemed"), max_digits=15, decimal_places=2, default=0) + loyalty_discount_amount = models.DecimalField(_("Loyalty Discount"), max_digits=15, decimal_places=3, default=0) payment_type = models.CharField(_("Payment Type"), max_length=20, choices=PAYMENT_TYPE_CHOICES, default='cash') status = models.CharField(_("Status"), max_length=20, choices=STATUS_CHOICES, default='paid') due_date = models.DateField(_("Due Date"), null=True, blank=True) @@ -290,6 +329,32 @@ class SystemSetting(models.Model): logo = models.ImageField(_("Logo"), upload_to="business_logos/", blank=True, null=True) vat_number = models.CharField(_("VAT Number"), max_length=50, blank=True) registration_number = models.CharField(_("Registration Number"), max_length=50, blank=True) + + # Loyalty Settings + loyalty_enabled = models.BooleanField(_("Enable Loyalty System"), default=False) + points_per_currency = models.DecimalField(_("Points Earned per Currency Unit"), max_digits=10, decimal_places=2, default=1.0) + currency_per_point = models.DecimalField(_("Currency Value per Point"), max_digits=10, decimal_places=3, default=0.010) + min_points_to_redeem = models.PositiveIntegerField(_("Minimum Points to Redeem"), default=100) def __str__(self): - return self.business_name \ No newline at end of file + return self.business_name + +class UserProfile(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") + image = models.ImageField(_("Profile Picture"), upload_to="profile_pics/", blank=True, null=True) + phone = models.CharField(_("Phone Number"), max_length=20, blank=True) + bio = models.TextField(_("Bio"), blank=True) + + def __str__(self): + return self.user.username + +@receiver(post_save, sender=User) +def create_user_profile(sender, instance, created, **kwargs): + if created: + UserProfile.objects.create(user=instance) + +@receiver(post_save, sender=User) +def save_user_profile(sender, instance, **kwargs): + if not hasattr(instance, 'profile'): + UserProfile.objects.create(user=instance) + instance.profile.save() diff --git a/core/templates/base.html b/core/templates/base.html index 61ca302..96b77d5 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -201,18 +201,39 @@