diff --git a/db.sqlite3 b/db.sqlite3 index 6a15e27f..7d98912a 100644 Binary files a/db.sqlite3 and b/db.sqlite3 differ diff --git a/finance/__pycache__/admin.cpython-312.pyc b/finance/__pycache__/admin.cpython-312.pyc index 8e8493b5..59a774f3 100644 Binary files a/finance/__pycache__/admin.cpython-312.pyc and b/finance/__pycache__/admin.cpython-312.pyc differ diff --git a/finance/__pycache__/forms.cpython-312.pyc b/finance/__pycache__/forms.cpython-312.pyc index daa4432e..6a081325 100644 Binary files a/finance/__pycache__/forms.cpython-312.pyc and b/finance/__pycache__/forms.cpython-312.pyc differ diff --git a/finance/__pycache__/models.cpython-312.pyc b/finance/__pycache__/models.cpython-312.pyc index 1cc919f9..b7a5dc7e 100644 Binary files a/finance/__pycache__/models.cpython-312.pyc and b/finance/__pycache__/models.cpython-312.pyc differ diff --git a/finance/__pycache__/views.cpython-312.pyc b/finance/__pycache__/views.cpython-312.pyc index 80710117..5fba3742 100644 Binary files a/finance/__pycache__/views.cpython-312.pyc and b/finance/__pycache__/views.cpython-312.pyc differ diff --git a/finance/admin.py b/finance/admin.py index 43c8a7f1..5eb466cb 100644 --- a/finance/admin.py +++ b/finance/admin.py @@ -9,6 +9,7 @@ from simple_history.admin import SimpleHistoryAdmin from .models import ( Service, Package, + PackageService, Payer, Invoice, InvoiceLineItem, @@ -45,6 +46,16 @@ class ServiceAdmin(admin.ModelAdmin): ) +class PackageServiceInline(admin.TabularInline): + """Inline admin for Package Services.""" + + model = PackageService + extra = 1 + readonly_fields = ['id'] + fields = ['service', 'sessions'] + autocomplete_fields = ['service'] + + @admin.register(Package) class PackageAdmin(admin.ModelAdmin): """Admin interface for Package model.""" @@ -52,15 +63,15 @@ class PackageAdmin(admin.ModelAdmin): list_display = ['name_en', 'total_sessions', 'price', 'validity_days', 'is_active', 'tenant'] list_filter = ['is_active', 'tenant'] search_fields = ['name_en', 'name_ar'] - readonly_fields = ['id', 'created_at', 'updated_at'] - filter_horizontal = ['services'] + readonly_fields = ['id', 'total_sessions', 'created_at', 'updated_at'] + inlines = [PackageServiceInline] fieldsets = ( (None, { 'fields': ('name_en', 'name_ar', 'tenant', 'is_active') }), (_('Package Details'), { - 'fields': ('services', 'total_sessions', 'price', 'validity_days') + 'fields': ('total_sessions', 'price', 'validity_days') }), (_('Description'), { 'fields': ('description',), diff --git a/finance/forms.py b/finance/forms.py index 0d395b16..b683c1eb 100644 --- a/finance/forms.py +++ b/finance/forms.py @@ -10,7 +10,7 @@ from django.utils.translation import gettext_lazy as _ from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Fieldset, Row, Column, Submit, HTML -from .models import Invoice, InvoiceLineItem, Payment, Service, Package, PackagePurchase, Payer +from .models import Invoice, InvoiceLineItem, Payment, Service, Package, PackageService, PackagePurchase, Payer class InvoiceForm(forms.ModelForm): @@ -284,14 +284,11 @@ class PackageForm(forms.ModelForm): model = Package fields = [ 'name_en', 'name_ar', - 'services', 'total_sessions', 'price', - 'validity_days', 'description', 'is_active', + 'price', 'validity_days', 'description', 'is_active', ] widgets = { 'name_en': forms.TextInput(attrs={'class': 'form-control'}), 'name_ar': forms.TextInput(attrs={'class': 'form-control'}), - 'services': forms.CheckboxSelectMultiple(), - 'total_sessions': forms.NumberInput(attrs={'class': 'form-control', 'min': '1'}), 'price': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01', 'min': '0'}), 'validity_days': forms.NumberInput(attrs={'class': 'form-control', 'min': '1'}), 'description': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}), @@ -306,32 +303,45 @@ class PackageForm(forms.ModelForm): if field_name == 'is_active': if 'class' not in field.widget.attrs: field.widget.attrs['class'] = 'form-check-input' - elif field_name != 'services': # services uses CheckboxSelectMultiple + else: if 'class' not in field.widget.attrs: field.widget.attrs['class'] = 'form-control' + + +class PackageServiceForm(forms.ModelForm): + """ + Form for package service items (service + session count). + """ + + class Meta: + model = PackageService + fields = ['service', 'sessions'] + widgets = { + 'service': forms.Select(attrs={'class': 'form-control select2', 'data-placeholder': 'Select service'}), + 'sessions': forms.NumberInput(attrs={'class': 'form-control', 'min': '1', 'value': '1'}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Filter to show only active services + self.fields['service'].queryset = Service.objects.filter(is_active=True) - self.helper = FormHelper() - self.helper.form_method = 'post' - self.helper.layout = Layout( - Fieldset( - _('Package Information'), - Row( - Column('name_en', css_class='form-group col-md-6 mb-0'), - Column('name_ar', css_class='form-group col-md-6 mb-0'), - css_class='form-row' - ), - 'services', - Row( - Column('total_sessions', css_class='form-group col-md-4 mb-0'), - Column('price', css_class='form-group col-md-4 mb-0'), - Column('validity_days', css_class='form-group col-md-4 mb-0'), - css_class='form-row' - ), - 'description', - 'is_active', - ), - Submit('submit', _('Save Package'), css_class='btn btn-primary') - ) + # Add CSS classes + for field_name, field in self.fields.items(): + if 'class' not in field.widget.attrs: + field.widget.attrs['class'] = 'form-control' + + +# Inline formset for package services +PackageServiceFormSet = inlineformset_factory( + Package, + PackageService, + form=PackageServiceForm, + extra=0, # Start with 1 empty form + can_delete=True, + min_num=1, + validate_min=True, +) class PackagePurchaseForm(forms.ModelForm): diff --git a/finance/migrations/0005_alter_package_total_sessions_packageservice_and_more.py b/finance/migrations/0005_alter_package_total_sessions_packageservice_and_more.py new file mode 100644 index 00000000..8409e1c9 --- /dev/null +++ b/finance/migrations/0005_alter_package_total_sessions_packageservice_and_more.py @@ -0,0 +1,73 @@ +# Generated by Django 5.2.3 on 2025-11-02 12:07 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +def migrate_package_services(apps, schema_editor): + """ + Migrate existing package-service relationships to the new through model. + """ + Package = apps.get_model('finance', 'Package') + PackageService = apps.get_model('finance', 'PackageService') + + # For each package, create PackageService entries for existing services + for package in Package.objects.all(): + # Get existing services through the old M2M relationship + for service in package.services.all(): + # Create a PackageService with default 1 session + PackageService.objects.create( + package=package, + service=service, + sessions=1 + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0004_csid'), + ] + + operations = [ + # Step 1: Alter total_sessions field + migrations.AlterField( + model_name='package', + name='total_sessions', + field=models.PositiveIntegerField(default=0, help_text='Auto-calculated from service sessions', verbose_name='Total Sessions'), + ), + + # Step 2: Create the PackageService model + migrations.CreateModel( + name='PackageService', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')), + ('sessions', models.PositiveIntegerField(default=1, help_text='Number of sessions for this service in the package', verbose_name='Number of Sessions')), + ('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='finance.package', verbose_name='Package')), + ('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='finance.service', verbose_name='Service')), + ], + options={ + 'verbose_name': 'Package Service', + 'verbose_name_plural': 'Package Services', + 'ordering': ['package', 'service'], + 'unique_together': {('package', 'service')}, + }, + ), + + # Step 3: Migrate existing data + migrations.RunPython(migrate_package_services, reverse_code=migrations.RunPython.noop), + + # Step 4: Remove the old M2M field + migrations.RemoveField( + model_name='package', + name='services', + ), + + # Step 5: Add the new M2M field with through + migrations.AddField( + model_name='package', + name='services', + field=models.ManyToManyField(related_name='packages', through='finance.PackageService', to='finance.service', verbose_name='Services'), + ), + ] diff --git a/finance/migrations/__pycache__/0005_alter_package_total_sessions_packageservice_and_more.cpython-312.pyc b/finance/migrations/__pycache__/0005_alter_package_total_sessions_packageservice_and_more.cpython-312.pyc new file mode 100644 index 00000000..575ba2d3 Binary files /dev/null and b/finance/migrations/__pycache__/0005_alter_package_total_sessions_packageservice_and_more.cpython-312.pyc differ diff --git a/finance/models.py b/finance/models.py index 467ea203..a39b06b5 100644 --- a/finance/models.py +++ b/finance/models.py @@ -89,11 +89,14 @@ class Package(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin): ) services = models.ManyToManyField( Service, + through='PackageService', related_name='packages', verbose_name=_("Services") ) total_sessions = models.PositiveIntegerField( - verbose_name=_("Total Sessions") + default=0, + verbose_name=_("Total Sessions"), + help_text=_("Auto-calculated from service sessions") ) price = models.DecimalField( max_digits=10, @@ -122,6 +125,51 @@ class Package(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin): def __str__(self): return f"{self.name_en} ({self.total_sessions} sessions)" + + def calculate_total_sessions(self): + """Calculate total sessions from all package services.""" + return sum(ps.sessions for ps in self.packageservice_set.all()) + + def save(self, *args, **kwargs): + """Override save to auto-calculate total sessions.""" + super().save(*args, **kwargs) + # Update total_sessions after save (when through relationships exist) + if self.pk: + self.total_sessions = self.calculate_total_sessions() + if self.total_sessions != self._state.fields_cache.get('total_sessions', 0): + Package.objects.filter(pk=self.pk).update(total_sessions=self.total_sessions) + + +class PackageService(UUIDPrimaryKeyMixin): + """ + Intermediate model linking packages to services with session counts. + Allows specifying how many sessions of each service are included in a package. + """ + + package = models.ForeignKey( + Package, + on_delete=models.CASCADE, + verbose_name=_("Package") + ) + service = models.ForeignKey( + Service, + on_delete=models.CASCADE, + verbose_name=_("Service") + ) + sessions = models.PositiveIntegerField( + default=1, + verbose_name=_("Number of Sessions"), + help_text=_("Number of sessions for this service in the package") + ) + + class Meta: + verbose_name = _("Package Service") + verbose_name_plural = _("Package Services") + unique_together = [['package', 'service']] + ordering = ['package', 'service'] + + def __str__(self): + return f"{self.package.name_en} - {self.service.name_en} ({self.sessions} sessions)" class Payer(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin): diff --git a/finance/templates/finance/package_form.html b/finance/templates/finance/package_form.html index e883ae41..1f30134c 100644 --- a/finance/templates/finance/package_form.html +++ b/finance/templates/finance/package_form.html @@ -25,15 +25,30 @@ .form-check-input { margin-top: 0.3rem; } - .select2-container--default .select2-selection--multiple { + .select2-container--default .select2-selection--single { border: 1px solid #dee2e6; border-radius: 0.375rem; min-height: 38px; } - .select2-container--default.select2-container--focus .select2-selection--multiple { + .select2-container--default.select2-container--focus .select2-selection--single { border-color: #007bff; box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); } + .service-row { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + padding: 1rem; + margin-bottom: 0.75rem; + } + .service-row:hover { + background-color: #e9ecef; + } + + .btn-remove-service { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + } {% endblock %} @@ -55,7 +70,7 @@ -